LoginSignup
28
16

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-08-04

はじめに

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

  • タップした場所を起点に吹き出しが出る
  • 吹き出しが出る方向を指定できる(8方向)

実装イメージ

簡単ですが吹き出しの構成はこんな感じです
balloonView-image.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)
    }

balloonView-label

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

代替テキスト

ソースコード

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

28
16
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
28
16