はじめに
チュートリアル機能などでみる「吹き出し」を実装してみました。
今回は、以下の2点の仕様に沿って実装してみます。
- タップした場所を起点に吹き出しが出る
- 吹き出しが出る方向を指定できる(8方向)
実装イメージ
吹き出し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)
}
最終的なサンプルでは、4つのタップ領域を用意して、それぞれ違う方向に吹き出しを表示してみています。
ソースコード
今回作成したサンプルのソースは以下のリポジトリにあります。
https://github.com/ddd503/BalloonView-Sample