Xcode
Instagram
iOS
UITextView
Swift

InstagramのStoryにのせる文字みたいなUITextView

Instagram のストーリーを投稿しようとしてたときに、ふと「この文字を縁取る白い背景はどうやって実現しているんだろう」と思い、作ってみました。

Instagram Story
今回作ったやつ

uploaded-1.gif
uploaded-2.gif


コード

下記にコメント番号に合わせて解説があります。

また、下記コードだけでは動きません。解説内の 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行目を伸ばす

1.png
2.png

また、repeat while 文 でこの処理を繰り返していますが、それは下記のような対応のためです。


もし1度のみのチェックだったら

もし repeat while 文 で繰り返し処理しなければ。

① 1,2行目を比較 → 狭い
② 1行目の左端を2行目に合わせる
③ 2,3行目を比較 → 狭い
④ 2行目の左端を3行目に合わせる

1.png
2.png
3.png
4.png

これで終わると、1,2行目のズレが小さく、角丸の処理がおかしくなります。


repeat while 文で繰り返し確認

上記の続きで、繰り返し処理すると、

⑤ 1,2行目比較 → 狭い
⑥ 1行目の左端を2行目に合わせる
⑦ 2,3行目比較 → ずれがない
⑧ 処理なし

5.png
6.png
6.png
6.png

これで小さすぎる隙間の連続が揃えられました。

ここまでの処理を左端も右端も行います。


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

c.png
b.png
a.png


4. 各角の種類を判定


各行の間の角4つ

上から2行づつ比較し、2行間の各角 (1行目の左下/1行目の右下/2行目の左上/2行目の右上) の角をどう加工するかチェックします。下に例を示します。

このような2行の時
1行目左下はexpansion
2行目左上はcontraction

1.png
2.png


それ以外の角


  • 最初の行の上部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 で実現したらめっちゃ楽だったり?

コメントお待ちしております!