Instagram のストーリーを投稿しようとしてたときに、ふと「この文字を縁取る白い背景はどうやって実現しているんだろう」と思い、作ってみました。
Instagram Story | 今回作ったやつ |
---|---|
コード
下記にコメント番号に合わせて解説があります。
また、下記コードだけでは動きません。解説内の extension
を併用してください!
class InstagramTextView: UITextView, UITextViewDelegate {
let cornerRadius: CGFloat = 8
let highlightColor: UIColor = UIColor.white
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
initialize()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initialize()
}
private func initialize() {
self.delegate = self
self.isScrollEnabled = false
self.textContainerInset = UIEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius)
}
override var intrinsicContentSize: CGSize {
self.set()
return super.intrinsicContentSize
}
override func layoutSubviews() {
super.layoutSubviews()
self.set()
}
func textViewDidChange(_ textView: UITextView) {
set()
}
func set() {
// 1. 各行の CGRect(rects) を取得
var rects = self.text.lineRanges.map({ range -> CGRect in
var rect = self.layoutManager.boundingRect(forGlyphRange: range, in: self.textContainer)
rect.origin.x += self.textContainerInset.left
rect.origin.y += self.textContainerInset.top
if !self.text.substring(with: range).isEmpty {
rect.origin.x -= cornerRadius
rect.size.width += cornerRadius * 2
}
rect.origin.y -= 1
rect.size.height += 1
return rect
})
// 2. 左端を揃える
repeat {
for i in rects.indices.dropLast() {
let diffMinX = rects[i].minX - rects[i+1].minX
guard abs(diffMinX) < cornerRadius * 2 && diffMinX != 0 && rects[i].width != 0 && rects[i+1].width != 0 else { continue }
switch diffMinX {
case ...0:
rects[i+1].size.width += abs(diffMinX)
rects[i+1].origin.x = rects[i].origin.x
case 0...:
rects[i].size.width += abs(diffMinX)
rects[i].origin.x = rects[i+1].origin.x
default:
break
}
}
} while rects.eachPair().filter({ $0.0.width != 0 && $0.1.width != 0 }).map({ abs($0.0.minX - $0.1.minX) }).contains(where: { $0 < self.cornerRadius * 2 && $0 != 0 })
// 2. 右端を揃える
repeat {
for i in rects.indices.dropLast() {
let diffMaxX = rects[i].maxX - rects[i+1].maxX
guard abs(diffMaxX) < cornerRadius * 2 && diffMaxX != 0 && rects[i].width != 0 && rects[i+1].width != 0 else { continue }
switch diffMaxX {
case ...0:
rects[i].size.width += abs(diffMaxX)
case 0...:
rects[i+1].size.width += abs(diffMaxX)
default:
break
}
}
} while rects.eachPair().filter({ $0.0.width != 0 && $0.1.width != 0 }).map({ abs($0.0.maxX - $0.1.maxX) }).contains(where: { $0 < self.cornerRadius * 2 && $0 != 0 })
// 3. rects に対応する BumpyCorner の配列(corners)を用意
var corners = [BumpyCorner](repeating: BumpyCorner(), count: rects.count)
// 4. 最初の行の上部2つの角, 最後の行の下部2つの角, は必ず丸い
if !corners.isEmpty {
corners[0].topLeft = .contraction
corners[0].topRight = .contraction
corners[corners.count - 1].bottomLeft = .contraction
corners[corners.count - 1].bottomRight = .contraction
}
// 4. 各行の間の角4つ
for i in rects.indices.dropLast() {
// 左端
let diffMinX = rects[i].minX - rects[i+1].minX
if diffMinX < 0 {
// 上の行が左側に出っ張る
corners[i].bottomLeft = .contraction
corners[i+1].topLeft = .expansion
} else if diffMinX > 0 {
// 下の行が左側に出っ張る
corners[i].bottomLeft = .expansion
corners[i+1].topLeft = .contraction
}
// 右端
let diffMaxX = rects[i].maxX - rects[i+1].maxX
if diffMaxX < 0 {
// 下の行が右側に出っ張る
corners[i].bottomRight = .expansion
corners[i+1].topRight = .contraction
} else if diffMaxX > 0 {
// 下の行が左側に出っ張る
corners[i].bottomRight = .contraction
corners[i+1].topRight = .expansion
}
}
// 4. 文字のないところは真っ直ぐ
for i in rects.indices where rects[i].width == 0 {
corners[i].topLeft = .none
corners[i].topRight = .none
corners[i].bottomLeft = .none
corners[i].bottomRight = .none
}
// 5. rects と corners を元に各行の UIBezierPath(paths) を用意
let paths = rects.indices.map({ UIBezierPath(rect: rects[$0], bumpyCorner: corners[$0], cornerRadius: cornerRadius) })
// 6. paths を元に CAShapeLayer を用意
self.layer.sublayers?.filter({ $0.name == "highlight" }).forEach({ $0.removeFromSuperlayer() })
for path in paths {
let shapeLayer = CAShapeLayer(fill: highlightColor, zPosition: -1, path: path, frame: self.bounds, name: "highlight")
self.layer.addSublayer(shapeLayer)
}
}
}
解説
コード内の各コメントの番号に対応してます。
前提の考え方
今回の大まかな実現手段として、
行ごとに分解する → 各行の角をどうするか(丸めたり)考える → 各行の背景にCALayerで白い背景を入れる
という発想で処理していきます。
1. 各行の CGRect(rects) を取得
まず各行の NSRange を求める
下記みたいな感じで各行の NSRange
を用意したい。
aiu -> NSRange(location: 0, length: 3)
e -> NSRange(location: 4, length: 1)
okakiku -> NSRange(location: 6, length: 7)
なので下記 extension を使用。
extension String {
var lineRanges: [NSRange] {
let lines = self.components(separatedBy: .newlines)
let ranges = lines.reduce([]) { result, line -> [NSRange] in
let location = result.map({ $0.length }).sum() + result.count
return result + [NSRange(location: location, length: line.count)]
}
return ranges
}
}
extension Array where Element == Int {
func sum() -> Int {
return self.reduce(0, { $0 + $1 })
}
}
各行の NSRange を元に CGRect を取得
各行の座標(CGRect)を取得するためには、 boundingRect(forGlyphRange:in:)
を使用します。
これらに関する解説は別記事で書きましたので御覧ください。
https://qiita.com/yuki0n0/items/1a15bde4e75f05c99681
また、 文字列があるとき(0文字で無い時) のみ左右にパディングを設けるように調整を加え、
上下の行で隙間がうまれないよう 1px 上にずらしています。
substring(with:) について
用意するまでも無いかもしれませんが、 String
のまま書きたかったので。
extension String {
func substring(with range: NSRange) -> String {
return (self as NSString).substring(with: range)
}
}
2. 端を揃える
上から2行づつ、左端の差を調べます。
もし cornerRadius * 2
未満のズレだと( 狭すぎると )、角丸が実現できないので、外側の端に座標を合わせます。
下記に例を示します。
1. 左が狭すぎる | 2. 外側(2行目)に合わせて1行目を伸ばす |
---|---|
また、repeat while 文
でこの処理を繰り返していますが、それは下記のような対応のためです。
もし1度のみのチェックだったら
もし repeat while 文
で繰り返し処理しなければ。
① 1,2行目を比較 → 狭い | ② 1行目の左端を2行目に合わせる | ③ 2,3行目を比較 → 狭い | ④ 2行目の左端を3行目に合わせる |
---|---|---|---|
これで終わると、1,2行目のズレが小さく、角丸の処理がおかしくなります。
repeat while 文で繰り返し確認
上記の続きで、繰り返し処理すると、
⑤ 1,2行目比較 → 狭い | ⑥ 1行目の左端を2行目に合わせる | ⑦ 2,3行目比較 → ずれがない | ⑧ 処理なし |
---|---|---|---|
これで小さすぎる隙間の連続が揃えられました。
ここまでの処理を左端も右端も行います。
eachPair() について
extension
を用意しています。詳しくは下記記事に書きました。
https://qiita.com/yuki0n0/items/af100a330a5a72cd067f
extension Array {
func eachPair() -> [(Element, Element)] {
return zip(self, self.dropFirst()).map({ ($0.0, $0.1) })
}
}
3. rects に対応する BumpyCorner の配列(corners)を用意
BumpyCorner
という構造体を用意しました。
struct BumpyCorner {
var topLeft: Kind
var topRight: Kind
var bottomLeft: Kind
var bottomRight: Kind
init(topLeft: Kind = .none, topRight: Kind = .none, bottomLeft: Kind = .none, bottomRight: Kind = .none) {
self.topLeft = topLeft
self.topRight = topRight
self.bottomLeft = bottomLeft
self.bottomRight = bottomRight
}
enum Kind {
case expansion // 膨張
case none // なし
case contraction // 収縮
}
}
角における形が、3種類あります。これを各角でどうするか保持する構造体です。
expansion | none | contraction |
---|---|---|
4. 各角の種類を判定
各行の間の角4つ
上から2行づつ比較し、2行間の各角 (1行目の左下/1行目の右下/2行目の左上/2行目の右上) の角をどう加工するかチェックします。下に例を示します。
このような2行の時 | 1行目左下はexpansion 2行目左上は contraction
|
---|---|
それ以外の角
- 最初の行の上部2つの角 / 最後の行の下部2つの角 は必ず
contraction
(丸い) - 文字が無い行 (width==0) は
none
(まっすぐ)
5. rects と corners を元に各行の UIBezierPath(paths) を用意
UIBezierPath の extension を用意しました。
BumpyCorner
を元に、各角のパスを描いていきます。
よくわからない方は調べてください。イメージ的にはただ線で輪郭を書いているだけです。
extension UIBezierPath {
convenience init(rect: CGRect, bumpyCorner corner: BumpyCorner, cornerRadius radius: CGFloat) {
self.init()
self.move(to: CGPoint(x: rect.origin.x + radius, y: rect.minY))
switch corner.topRight {
case .expansion:
self.addLine(to: CGPoint(x: rect.maxX + radius, y: rect.minY))
self.addArc(withCenter: CGPoint(x: rect.maxX + radius, y: rect.minY + radius), radius: radius, startAngle: CGFloat.pi * 1.5, endAngle: CGFloat.pi, clockwise: false)
case .none:
self.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
case .contraction:
self.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.minY))
self.addArc(withCenter: CGPoint(x: rect.maxX - radius, y: rect.minY + radius), radius: radius, startAngle: CGFloat.pi * 1.5, endAngle: 0, clockwise: true)
}
switch corner.bottomRight {
case .expansion:
self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - radius))
self.addArc(withCenter: CGPoint(x: rect.maxX + radius, y: rect.maxY - radius), radius: radius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 0.5, clockwise: false)
case .none:
self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
case .contraction:
self.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - radius))
self.addArc(withCenter: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius), radius: radius, startAngle: 0, endAngle: CGFloat.pi * 0.5, clockwise: true)
}
switch corner.bottomLeft {
case .expansion:
self.addLine(to: CGPoint(x: rect.minX - radius, y: rect.maxY))
self.addArc(withCenter: CGPoint(x: rect.minX - radius, y: rect.maxY - radius), radius: radius, startAngle: CGFloat.pi * 0.5, endAngle: 0, clockwise: false)
case .none:
self.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
case .contraction:
self.addLine(to: CGPoint(x: rect.minX + radius, y: rect.maxY))
self.addArc(withCenter: CGPoint(x: rect.minX + radius, y: rect.maxY - radius), radius: radius, startAngle: CGFloat.pi * 0.5, endAngle: CGFloat.pi, clockwise: true)
}
switch corner.topLeft {
case .expansion:
self.addLine(to: CGPoint(x: rect.minX, y: rect.minY + radius))
self.addArc(withCenter: CGPoint(x: rect.minX - radius, y: rect.minY + radius), radius: radius, startAngle: 0, endAngle: CGFloat.pi * 1.5, clockwise: false)
case .none:
self.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
case .contraction:
self.addLine(to: CGPoint(x: rect.minX, y: rect.maxY + radius))
self.addArc(withCenter: CGPoint(x: rect.minX + radius, y: rect.minY + radius), radius: radius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 1.5, clockwise: true)
}
self.close()
}
}
6. paths を元に CAShapeLayer を用意
用意したパスを元にレイヤーを用意して、表示させます。
extension CAShapeLayer {
convenience init(fill color: UIColor?, zPosition: CGFloat = 0, path: UIBezierPath, frame: CGRect, name: String? = nil) {
self.init()
self.fillColor = color?.cgColor
self.zPosition = zPosition
self.path = path.cgPath
self.frame = frame
self.name = name
}
}
環境
- Swift 5
- Xcode 10.2.1
- iOS 12.1
後記
業務で必要に迫られたわけではなく、興味本位でなんとなく書いてみたものなので、結構無理矢理感があります笑。
set()
関数内のコードがめちゃくちゃ長かったり、中途半端に extension にかき分けたり、map / filter / reduce 周りを多用したり、とてもじゃないけど綺麗なコードとは言えないと思うので、各自綺麗にリファクタリングしてみてください。
処理内容的におかしかったり、もっと良い方法があればぜひ教えてください!
そもそも行ごとに行う処理方法は良いのか、左右の端を揃える処理はどうか、などなど。
WebView で HTML CSS で実現したらめっちゃ楽だったり?
コメントお待ちしております!