xyk blog

最近は iOS 開発の記事が多めです。

Swift - UIBezierPath で吹き出しのパスを描く

検証環境:
Xcode 11.6
Swift 5.2.4

f:id:xyk:20200723110904p:plain

こんな吹き出しを UIBezierPath を使って描画してみます。
パスの描画順は以下図の流れになっています。

f:id:xyk:20200815173152j:plain

吹き出し部分は addQuadCurve メソッドを使い、曲線上の終点とコントロールポイント1つを指定します。
この時の終点は円周上にありますが、円周上の点の座標の求め方は以下図のように三角関数を利用します。

f:id:xyk:20200815234039j:plain

以下はPlayground で実装したコードサンプルです。

import UIKit
import PlaygroundSupport

class CalloutView: UIView {
    
    private let lineWidth: CGFloat = 8
    private let strokeColor = UIColor.systemRed
    private let arrowHeight: CGFloat = 36
    
    private lazy var borderLayer: CAShapeLayer = {
        let shapeLayer = CAShapeLayer()
        shapeLayer.lineWidth = lineWidth
        shapeLayer.strokeColor = strokeColor.cgColor
        shapeLayer.fillColor = UIColor.white.cgColor
        return shapeLayer
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        configure()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        borderLayer.path = makeCalloutPath(rect: bounds)
    }
    
    private func configure() {
        //layer.backgroundColor = UIColor.systemGray4.cgColor // 矩形確認用
        layer.addSublayer(borderLayer)
    }
    
    private func makeCalloutPath(rect: CGRect) -> CGPath {
        let centerX: CGFloat = rect.midX
        let centerY: CGFloat
        let radius: CGFloat
        
        if rect.width > rect.height - arrowHeight {
            radius = (rect.maxY - arrowHeight - lineWidth) / 2
            centerY = radius + lineWidth / 2
        } else {
            radius = (rect.maxX - lineWidth) / 2
            centerY = rect.midY - arrowHeight / 2
        }
        
        let controlPoint = CGPoint(x: centerX, y: centerY + radius + arrowHeight * 2)

        let path = UIBezierPath()
        // 1
        path.addArc(withCenter: CGPoint(x: centerX, y: centerY), radius: radius,
                    startAngle: radian(270), endAngle: 0, clockwise: true)
        // 2        
        path.addArc(withCenter: CGPoint(x: centerX, y: centerY), radius: radius,
                    startAngle: 0, endAngle: radian(75), clockwise: true)
        // 3
        path.addQuadCurve(to: CGPoint(x: centerX - (radius * sin(radian(15))),
                                      y: centerY + (radius * cos(radian(15)))),
                          controlPoint: controlPoint)
        // 4
        path.addArc(withCenter: CGPoint(x: centerX, y: centerY), radius: radius,
                    startAngle: radian(105), endAngle: radian(180), clockwise: true)
        // 5
        path.addArc(withCenter: CGPoint(x: centerX, y: centerY), radius: radius,
                    startAngle: radian(180), endAngle: radian(270), clockwise: true)
        
        path.close()
        
        return path.cgPath
    }
    
    private func radian(_ degree: CGFloat) -> CGFloat {
        return degree * .pi / 180.0
    }
}

class MyViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemGray5
        
        let calloutView = CalloutView()
        view.addSubview(calloutView)
        calloutView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            calloutView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            calloutView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            calloutView.widthAnchor.constraint(equalToConstant: 250),
            calloutView.heightAnchor.constraint(equalToConstant: 286),
        ])
    }
}
PlaygroundPage.current.liveView = MyViewController()

その他の UIBezierPath シリーズ

xyk.hatenablog.com

xyk.hatenablog.com

xyk.hatenablog.com