【Swift】タップ位置から「吹き出し」を表示する

# はじめに
チュートリアル機能などでみる「**吹き出し**」を実装してみました。
今回は、以下の2点の仕様に沿って実装してみます。

- タップした場所を起点に吹き出しが出る
- 吹き出しが出る方向を指定できる（８方向）

# 実装イメージ
簡単ですが吹き出しの構成はこんな感じです
<img width="350" alt="balloonView-image.png" src="https://github.com/ddd503/Image-Resource/raw/master/image/balloonView-description2.png">

# 吹き出しView用のサブクラスを用意
このサブクラス内では以下の流れで処理を行います。

**init時**
- 親View(以降BalloonView)のFrameを決定
- 子View(以降innerView)のFrameを「吹き出しに入れたいView(以降contentView)」のSizeを元に決定
- innerViewに、contentViewをaddSubView

```BalloonView.swift
    /// イニシャライズ
    ///
    /// - Parameters:
    ///   - focusPoint: 吹き出しが出る地点(三角形の頂点)
    ///   - contentView: 吹き出しの中に入れたいView（今回は長方形のUILabelを渡しています）
    ///   - color: 吹き出しの色
    ///   - directionType: 吹き出すを出す方向
    ///   - triangleBottomLength: 三角形部分の幅
    ///   - triangleHeight: 三角形部分の高さ
    init(focusPoint: CGPoint, contentView: UIView,
         color: UIColor, directionType: BalloonViewDirectionType,
         triangleBottomLength: CGFloat = 25, triangleHeight: CGFloat = 20) {
        self.color = color
        self.directionType = directionType
        self.triangleBottomLength = triangleBottomLength
        self.triangleHeight = triangleHeight

        let viewSize = directionType.viewSize(contentViewSize: contentView.frame.size, triangleHeight: triangleHeight)
        let viewOrigin = directionType.viewOrigin(focusPoint: focusPoint, viewSize: viewSize)
        let viewFrame = CGRect(origin: viewOrigin, size: viewSize)

        // 吹き出しの内容部分描画用のViewを用意
        let innerViewSize = directionType.innerViewSize(superViewFrame: viewFrame, triangleHeight: triangleHeight)
        let innerViewOrigin = directionType.innerViewOrigin(triangleHeight: triangleHeight)
        innerView = UIView(frame: CGRect(origin: innerViewOrigin, size: innerViewSize))

        super.init(frame: viewFrame)

        // BalloonView自体の背景を透明に（吹き出しのみを見せるため）
        backgroundColor = .clear

        innerView.backgroundColor = color
        addSubview(innerView)
        innerView.addSubview(contentView)
        contentView.center = self.convert(innerView.center, to: innerView)
    }
```

今回は、「吹き出しを出す方向」を定義するenum(BalloonViewDirectionType)に、諸々のFrame生成処理を任せています。

```BalloonViewDirectionType.swift
    /// BalloonView(長方形&三角形を含む)のサイズを返す
    ///
    /// - Parameters:
    ///   - contentViewSize: 吹き出し内に入れたいViewのサイズ
    ///   - triangleHeight: 三角形部分(吹き出し)の高さ
    /// - Returns: BalloonViewのサイズ
    func viewSize(contentViewSize: CGSize, triangleHeight: CGFloat) -> CGSize {
        switch self {
        case .up, .under, .upperRight, .lowerRight, .upperLeft, .lowerLeft:
            return CGSize(width: contentViewSize.width + expandLength.width,
                          height: contentViewSize.height + expandLength.height + triangleHeight)
        case .right, .left:
            return CGSize(width: contentViewSize.width + expandLength.width + triangleHeight,
                          height: contentViewSize.height + expandLength.height)
        }
    }

    /// BalloonView(長方形&三角形を含む)のOriginを返す
    ///
    /// - Parameters:
    ///   - focusPoint: 吹き出しが出る地点
    ///   - viewSize: BalloonViewのサイズ
    /// - Returns: BalloonViewのOrigin
    func viewOrigin(focusPoint: CGPoint, viewSize: CGSize) -> CGPoint {
        switch self {
        case .up:
            return CGPoint(x: focusPoint.x - viewSize.width / 2, y: focusPoint.y - viewSize.height)
        case .under:
            return CGPoint(x: focusPoint.x - viewSize.width / 2, y: focusPoint.y)
        case .right:
            return CGPoint(x: focusPoint.x, y: focusPoint.y - (viewSize.height / 2))
        case .left:
            return CGPoint(x: focusPoint.x - viewSize.width, y: focusPoint.y - (viewSize.height / 2))
        case .upperRight:
            return CGPoint(x: focusPoint.x, y: focusPoint.y - viewSize.height)
        case .lowerRight:
            return focusPoint
        case .upperLeft:
            return CGPoint(x: focusPoint.x - viewSize.width, y: focusPoint.y - viewSize.height)
        case .lowerLeft:
            return CGPoint(x: focusPoint.x - viewSize.width, y: focusPoint.y)
        }
    }

    /// 吹き出し内容描画用Viewのサイズを返す
    ///
    /// - Parameters:
    ///   - superViewFrame: BalloonViewのframe
    ///   - triangleHeight: 三角形部分(吹き出し)の高さ
    /// - Returns: 吹き出し内容描画用Viewのサイズ
    func innerViewSize(superViewFrame: CGRect, triangleHeight: CGFloat) -> CGSize {
        switch self {
        case .up, .under:
            return CGSize(width: superViewFrame.size.width, height: superViewFrame.size.height - triangleHeight)
        case .right, .left:
            return CGSize(width: superViewFrame.size.width - triangleHeight, height: superViewFrame.size.height)
        case .upperRight, .lowerRight, .upperLeft, .lowerLeft:
            return CGSize(width: superViewFrame.width, height: superViewFrame.height - triangleHeight)
        }
    }

    /// 吹き出し内容描画用ViewのOriginを返す
    ///
    /// - Parameter triangleHeight: 三角形部分(吹き出し)の高さ
    /// - Returns: 吹き出し内容描画用ViewのOrigin
    func innerViewOrigin(triangleHeight: CGFloat) -> CGPoint {
        switch self {
        case .up, .left, .upperRight, .upperLeft:
            return .zero
        case .under:
            return CGPoint(x: .zero, y: triangleHeight)
        case .right:
            return CGPoint(x: triangleHeight, y: .zero)
        case .lowerRight, .lowerLeft:
            return CGPoint(x: .zero, y: triangleHeight)
        }
    }
```


**draw時**
- UIBezierPathを用いて、三角形部分をBalloonViewに描画する

```BalloonView.swift
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        innerView.layer.masksToBounds = true
        innerView.layer.cornerRadius = 10

        // 吹き出しの三角形部分を描画する
        drawBalloonPath(rect: rect)
    }

    /// 吹き出しの三角形部分を描画する
    ///
    /// - Parameter rect: BalloonView自体のFrame
    func drawBalloonPath(rect: CGRect) {
        // 三角形の各頂点を取得
        let cornerPoints = directionType.triangleCornerPoints(superViewRect: rect,
                                                              triangleBottomLength: triangleBottomLength,
                                                              triangleHeight: triangleHeight)
        // 三角形の描画
        let triangle = UIBezierPath()
        triangle.move(to: cornerPoints.left)
        triangle.addLine(to: cornerPoints.top)
        triangle.addLine(to: cornerPoints.right)
        triangle.close()
        // 内側の色をセット
        color.setFill()
        // 内側を塗りつぶす
        triangle.fill()
    }
```

三角形の各頂点を決定する処理に関しても、BalloonViewDirectionTypeに任せています。

```BalloonViewDirectionType.swift
    /// 三角形部分(吹き出し)描画用の頂点(3つ)の座標を返す
    ///
    /// - Parameters:
    ///   - superViewRect: BalloonViewのframe
    ///   - triangleBottomLength: 三角形の底辺の長さ
    ///   - triangleHeight: 三角形の高さ
    /// - Returns: 三角形部分(吹き出し)描画用の頂点(3つ)の座標
    func triangleCornerPoints(superViewRect: CGRect,
                              triangleBottomLength: CGFloat,
                              triangleHeight: CGFloat) -> (top: CGPoint, left: CGPoint, right: CGPoint) {
        let top: CGPoint
        let left: CGPoint
        let right: CGPoint
        let triangleBottomLengthHalf = triangleBottomLength / 2
        // 斜め方向の三角部分の開始位置決定用
        let diagonallyDirectionTriangleBottomCenterX = superViewRect.size.width * 0.2
        let shortLength = diagonallyDirectionTriangleBottomCenterX - triangleBottomLengthHalf
        let longLength = diagonallyDirectionTriangleBottomCenterX + triangleBottomLengthHalf

        switch self {
        case .up:
            top = CGPoint(x: superViewRect.size.width / 2, y: superViewRect.size.height)
            left = CGPoint(x: top.x + triangleBottomLengthHalf, y: top.y - triangleHeight)
            right = CGPoint(x: top.x - triangleBottomLengthHalf, y: left.y)
        case .under:
            top = CGPoint(x: superViewRect.size.width / 2, y: .zero)
            left = CGPoint(x: top.x - triangleBottomLengthHalf, y: top.y + triangleHeight)
            right = CGPoint(x: top.x + triangleBottomLengthHalf, y: left.y)
        case .right:
            top = CGPoint(x: .zero, y: superViewRect.size.height / 2)
            left = CGPoint(x: top.x + triangleHeight, y: top.y + triangleBottomLengthHalf)
            right = CGPoint(x: left.x, y: top.y - triangleBottomLengthHalf)
        case .left:
            top = CGPoint(x: superViewRect.size.width, y: superViewRect.size.height / 2)
            left = CGPoint(x: top.x - triangleHeight, y: top.y - triangleBottomLengthHalf)
            right = CGPoint(x: left.x, y: top.y + triangleBottomLengthHalf)
        case .upperRight:
            top = CGPoint(x: superViewRect.origin.x, y: superViewRect.size.height)
            left = CGPoint(x: top.x + longLength, y: top.y - triangleHeight)
            right = CGPoint(x: top.x + shortLength, y: left.y)
        case .lowerRight:
            top = superViewRect.origin
            left = CGPoint(x: top.x + shortLength, y: top.y + triangleHeight)
            right = CGPoint(x: top.x + longLength, y: left.y)
        case .upperLeft:
            top = CGPoint(x: superViewRect.size.width, y: superViewRect.size.height)
            left = CGPoint(x: top.x - shortLength, y: top.y - triangleHeight)
            right = CGPoint(x: top.x - longLength, y: left.y)
        case .lowerLeft:
            top = CGPoint(x: superViewRect.size.width, y: .zero)
            left = CGPoint(x: top.x - longLength, y: top.y + triangleHeight)
            right = CGPoint(x: top.x - shortLength, y: left.y)
        }

        return (top, left, right)
    }
```

# 画面表示
用意した吹き出しView(BalloonView)をViewController側で生成、表示してみます。
今回は、タップ地点からBalloonViewが表示されるようにするため、UITapGestureRecognizerクラスを拡張して吹き出し表示メソッドを持たせています。

```ViewController.swift
private extension UITapGestureRecognizer {
    /// タップした場所にBalloonViewを表示する
    ///
    /// - Parameters:
    ///   - color: 吹き出しの色
    ///   - contentView: 吹き出し内に入れたいView
    ///   - directionType: 吹き出しを出したい方向
    func showBalloonView(color: UIColor, contentView: UIView, directionType: BalloonViewDirectionType) {
        guard let tappedView = self.view else { return }

        // 吹き出しの表示数はタップしたView内で1つのみとする
        tappedView.subviews.forEach {
            if $0 is BalloonView {
                $0.removeFromSuperview()
            }
        }

        let tapPosition = self.location(in: tappedView)
        let balloonView = BalloonView(focusPoint: tapPosition,
                                      contentView: contentView,
                                      color: color,
                                      directionType: directionType)
        balloonView.alpha = 0
        tappedView.addSubview(balloonView)

        UIView.animate(withDuration: 0.3) {
            balloonView.alpha = 1.0
        }
    }
}
```

あとはタップイベントを受けて、用意したメソッドを呼ぶだけです。

```ViewController.swift
   @IBAction func tappedRedView(_ sender: UITapGestureRecognizer) {
        let titleLabel = UILabel(frame: CGRect(origin: .zero, size: .zero))
        titleLabel.textAlignment = .center
        titleLabel.text = "こんにちは！"
        titleLabel.sizeToFit()
        sender.showBalloonView(color: .white, contentView: titleLabel, directionType: .up)
    }
```

<img width="200" alt="balloonView-label" src="https://github.com/ddd503/Image-Resource/raw/master/balloonView-label.gif">


最終的なサンプルでは、4つのタップ領域を用意して、それぞれ違う方向に吹き出しを表示してみています。

<img width="250" alt="代替テキスト" src="https://github.com/ddd503/Image-Resource/raw/master/balloonView-action2.gif">

# ソースコード
今回作成したサンプルのソースは以下のリポジトリにあります。
https://github.com/ddd503/BalloonView-Sample