10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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 で実現したらめっちゃ楽だったり?

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

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?