【Swift】 円形に拡散するボタンを実装

# はじめに
以下のようなTwitterのツイートボタンライクなUIを実装してみます。
<img width="200" alt="tweet_button" src="https://github.com/ddd503/Image-Resource/raw/master/tweet_button.gif">

# 実装イメージ
複数のボタンを重ねて配置し、アニメーションで拡散するようにしてみます。
<img width="180" alt="circleButton_image" src="https://github.com/ddd503/Image-Resource/raw/master/circleButton_image.png"> <img width="150" alt="circleButton_image_top" src="https://github.com/ddd503/Image-Resource/raw/master/circleButton_image_top.jpeg">

# レイアウト実装
今回は、UIViewのサブクラスを用意して、そのクラス内で必要なボタンを全て配置してみるようにします。

```CircleSpreadButtonView.swift
class CircleSpreadButtonView: UIView {}
```

なので、CircleSpreadButtonViewのinit時に配置するボタンに関する情報を渡してあげます。
今回は以下のstructにまとめて渡すようにしました。

```CircleSpreadButtonInfo.swift
import UIKit

// 円の中心のボタン情報
struct CenterButtonInfo {
    let title: String
    let backgroundColor: UIColor
    let backgroundImage: UIImage?
    let type: CircleSpreadButtonType?

    init(title: String = "", backgroundColor: UIColor = .white, backgroundImage: UIImage? = nil, type: CircleSpreadButtonType? = nil) {
        self.title = title
        self.backgroundColor = backgroundColor
        self.backgroundImage = backgroundImage
        self.type = type
    }
}

// 円形に広がるボタン情報
struct SpreadButtonInfo {
    let title: String
    let backgroundColor: UIColor
    let backgroundImage: UIImage?
    let type: CircleSpreadButtonType?
    let task: () -> ()

    init(title: String = "", backgroundColor: UIColor = .white, backgroundImage: UIImage? = nil, type: CircleSpreadButtonType? = nil, task: @escaping () -> ()) {
        self.title = title
        self.backgroundColor = backgroundColor
        self.backgroundImage = backgroundImage
        self.type = type
        self.task = task
    }
}
```

structの引数にCircleSpreadButtonTypeという型がありますが、これはデフォルトで用意したボタン情報を使用する際に渡すenumですので、独自でボタン情報を設定する場合はnilで問題ないです。

CircleSpreadButtonViewクラスのinit時に作成したstructを渡します。

```CircleSpreadButtonView.swift
/// イニシャライズ
    ///
    /// - Parameters:
    ///   - origin: origin
    ///   - length: 自身の長さ（絶対正方形にしたいので一辺の長さだけ渡します）
    ///   - firstSpreadButtonCenterAngle: 一つ目の拡散用ボタンの中心ボタンから見た配置角度（以降は等間隔で配置される）
    ///   - spreadButtonAngleInterval: 拡散用ボタンの配置間隔の角度
    ///   - centerButtonInfo: 中心ボタン情報
    ///   - spreadButtonInfoArray: 拡散用ボタン情報の配列
    init(origin: CGPoint, length: CGFloat = 80,
         firstSpreadButtonCenterAngle: Double = 100, spreadButtonAngleInterval: Double = 40,
         centerButtonInfo: CenterButtonInfo, spreadButtonInfoArray: [SpreadButtonInfo]) {
        self.origin = origin
        viewSize = CGSize(width: length, height: length)
        self.centerButtonInfo = centerButtonInfo
        self.spreadButtonInfoArray = spreadButtonInfoArray
        self.firstSpreadButtonCenterAngle = firstSpreadButtonCenterAngle
        self.spreadButtonAngleInterval = spreadButtonAngleInterval
        super.init(frame: CGRect(origin: origin, size: viewSize))

        // 自身を円形にする
        roundCorner(view: self)
        // 拡散用ボタンの生成・配置
        setupSpreadButton()
        // 中心ボタンの生成・配置
        setupCenterButton()
    }

     // 拡散用ボタンの生成・配置
     func setupSpreadButton() {
        spreadButtonInfoArray.enumerated().forEach { [weak self] (index, spreadButtonInfo) in
            guard let self = self else { return }
            let spreadButton = UIButton(frame: CGRect(origin: .zero,
                                                      size: CGSize(width: self.viewSize.width * self.spreadButtonSizeParcentage,
                                                                   height: self.viewSize.height * self.spreadButtonSizeParcentage)))
            spreadButton.setBackgroundImage(spreadButtonInfo.type?.backgroundImage ?? spreadButtonInfo.backgroundImage,
                                            for: .normal)
            spreadButton.center = self.roundViewCenter(length: self.viewSize.width)
            spreadButton.backgroundColor = spreadButtonInfo.type?.backgroundColor ?? spreadButtonInfo.backgroundColor
            spreadButton.setTitle(spreadButtonInfo.type?.title ?? spreadButtonInfo.title, for: .normal)
            spreadButton.setTitleColor(.white, for: .normal)
            spreadButton.setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted)
            spreadButton.titleLabel?.lineBreakMode = .byTruncatingTail
            spreadButton.tag = index + 1
            spreadButton.addTarget(self, action: #selector(spreadButtonAction(sender:)), for: .touchUpInside)
            self.addSubview(spreadButton)
            self.roundCorner(view: spreadButton)
        }
    }

    // 中心ボタンの生成・配置
    func setupCenterButton() {
        let centerButton = UIButton(frame: CGRect(origin: .zero, size: viewSize))
        centerButton.setBackgroundImage(centerButtonInfo.type?.backgroundImage ?? centerButtonInfo.backgroundImage,
                                        for: .normal)
        centerButton.backgroundColor = centerButtonInfo.type?.backgroundColor ?? centerButtonInfo.backgroundColor
        centerButton.setTitle(centerButtonInfo.type?.title ?? centerButtonInfo.title, for: .normal)
        centerButton.setTitleColor(.white, for: .normal)
        centerButton.setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted)
        centerButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 24)
        centerButton.addTarget(self, action: #selector(centerButtonAction(sender:)), for: .touchUpInside)
        addSubview(centerButton)
        roundCorner(view: centerButton)
    }
```

※ 実装の細かい部分に関しては、記事の最後にリポジトリのURLを貼ってありますので、そちらを参照ください

# アニメーションの実装
用意したボタンを拡散させるアニメーションを実装します。
上で用意した中心ボタンのタップをトリガーにアニメーションさせるようにします。
ちょっと長くなってますが、やっていることは単純で、各ボタンのアニメーション後の座標計算と実行だけです。

```CircleSpreadButtonView.swift
    @objc func centerButtonAction(sender: UIButton) {
        // 連打防止
        sender.isEnabled = false

        // 拡散用ボタンとアニメーション後のcenterの座標を保持
        var buttonPairs = [(button: UIButton, center: CGPoint)]()

        // アニメーション後のこのクラスのサイズ
        let spreadViewSize = isOpen ? viewSize : CGSize(width: viewSize.width * spreadViewSizeParcentage,
                                                        height: viewSize.height * spreadViewSizeParcentage)
        // アニメーションさせる前のcenter座標を保持（アニメーション時の位置調節に使用）
        let spreadViewCenterBeforeTransform = center

        // アニメーション後の中心ボタンのcenter座標
        let centerButtonCenter = CGPoint(x: spreadViewSize.width / 2, y: spreadViewSize.height / 2)

        // アニメーション後のこのクラスのcenter座標
        let spreadViewCenterAfterTransform = self.roundViewCenter(length: spreadViewSize.width)

        // アニメーション後の拡散用ボタンのcenter座標を生成
        spreadButtonInfoArray.enumerated().forEach { [weak self] (index, _) in
            guard let self = self, let spreadButton = self.viewWithTag(index + 1) as? UIButton else {
                sender.isEnabled = true
                return
            }

            // 角度ごとの円形状の座標を取得
            let degree = firstSpreadButtonCenterAngle + (spreadButtonAngleInterval * Double(index))
            let circumferencePoint = self.convert(self.circumferenceCoordinate(degree: degree,
                                                                               radius: (spreadViewSize.width / 2) - (spreadButton.frame.width * 0.6)), to: self)
            // Viewの配置位置に合わせて、アニメーション後の拡散用ボタンのcenter座標を計算
            let spreadButtonCenter = CGPoint(x: self.isOpen ? spreadViewCenterAfterTransform.x : spreadViewCenterAfterTransform.x + circumferencePoint.x,
                                             y: self.isOpen ? spreadViewCenterAfterTransform.y : spreadViewCenterAfterTransform.y + circumferencePoint.y)
            buttonPairs.append((spreadButton, spreadButtonCenter))
        }

        // アニメーションの用意
        let animation = { [weak self] () -> () in
            guard let self = self else { return }
            // 本Viewを円形に広げるアニメーション
            self.frame.size = spreadViewSize
            self.center = spreadViewCenterBeforeTransform
            self.roundCorner(view: self)

            // 本Viewのアニメーションに合わせて中心ボタンもアニメーションさせる
            sender.center = centerButtonCenter

            // 生成した拡散用ボタンをアニメーションさせる（中心座標の移動）
            buttonPairs.forEach({ (button, center) in
                button.center = center
            })
        }

        let animator = self.isOpen ?
            UIViewPropertyAnimator(duration: 0.1, curve: .linear, animations: {
                animation()
            })
            :
            UIViewPropertyAnimator(duration: 0.7, dampingRatio: 0.5) {
                animation()
        }

        animator.addCompletion { [weak self] (_) in
            self?.isOpen.toggle()
            sender.isEnabled = true
        }

        // アニメーションの実行
        animator.startAnimation()
    }
```

※ 拡散用ボタンのみ移動させるのではなく、CircleSpreadButtonView自体も拡散に合わせて変形させているのは、親Viewの範囲外まで子Viewが移動するとタップジェスチャーを認識できないためです。

# 動作確認
実際にCircleSpreadButtonViewをinitしてレイアウトを確認します。

```ViewController.swift
        let centerButtonInfo = CenterButtonInfo(title: "Tap", backgroundColor: .black)

        let redButtonInfo = SpreadButtonInfo(title: "赤", backgroundColor: .red, backgroundImage: nil, type: nil, task: {
            print("赤ボタンを押したよ")
        })
        let blueButtonInfo = SpreadButtonInfo(title: "青", backgroundColor: .blue, backgroundImage: nil, type: nil, task: {
            print("青ボタンを押したよ")
        })
        let greenButtonInfo = SpreadButtonInfo(title: "緑", backgroundColor: .green, backgroundImage: nil, type: nil, task: {
            print("緑ボタンを押したよ")
        })

        let circleSpreadButtonView = CircleSpreadButtonView(origin: view.center,
                                                            length: 80,
                                                            centerButtonInfo: centerButtonInfo,
                                                            spreadButtonInfoArray: [redButtonInfo, blueButtonInfo, greenButtonInfo])
```

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

デフォルトで用意したenumでボタンを生成する場合

```ViewController.swift
        let centerButtonInfo = CenterButtonInfo(title: "Tap", backgroundColor: .black)

        let cameraButtonInfo = SpreadButtonInfo(type: .camera, task: {
            print("カメラ")
        })
        let libraryButtonInfo = SpreadButtonInfo(type: .library, task: {
            print("フォトライブラリ")
        })
        let writeButtonInfo = SpreadButtonInfo(type: .write, task: {
            print("作成")
        })

        let circleSpreadButtonView = CircleSpreadButtonView(origin: view.center,
                                                            length: 80,
                                                            centerButtonInfo: centerButtonInfo,
                                                            spreadButtonInfoArray: [cameraButtonInfo, libraryButtonInfo, writeButtonInfo])
```

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

円形に広がるということで、こんな感じもやってみました。

```ViewController.swift
        let centerButtonInfo = CenterButtonInfo(title: "Tap", backgroundColor: .black)

        let b1 = SpreadButtonInfo(title: "😆", backgroundColor: .red, task: { print("red") })
        let b2 = SpreadButtonInfo(title: "😃", backgroundColor: .blue, task: { print("blue") })
        let b3 = SpreadButtonInfo(title: "😌", backgroundColor: .green, task: { print("green") })
        let b4 = SpreadButtonInfo(title: "😝", backgroundColor: .cyan, task: { print("cyan") })
        let b5 = SpreadButtonInfo(title: "😍", backgroundColor: .purple, task: { print("purple") })
        let b6 = SpreadButtonInfo(title: "😀", backgroundColor: .orange, task: { print("orange") })
        let b7 = SpreadButtonInfo(title: "🤓", backgroundColor: .brown, task: { print("brown") })
        let b8 = SpreadButtonInfo(title: "😁", backgroundColor: .magenta, task: { print("magenta") })
        let b9 = SpreadButtonInfo(title: "😊", backgroundColor: .yellow, task: { print("yellow") })

        let length: CGFloat = 80
        let centerOrigin = CGPoint(x: (view.frame.width / 2) - (length / 2), y: view.frame.height / 2 - (length / 2))
        let circleSpreadButtonView = CircleSpreadButtonView(origin: centerOrigin,
                                                            length: length,
                                                            firstSpreadButtonCenterAngle: 0,
                                                            centerButtonInfo: centerButtonInfo,
                                                            spreadButtonInfoArray: [b1, b2, b3, b4, b5, b6, b7, b8, b9])
```

<img width="250" alt="circleButton_sample_colorful" src="https://github.com/ddd503/Image-Resource/raw/master/circleButton_sample_colorful.gif">
# ソースコード
今回作ったサンプルのソースは以下のリポジトリにあります。
https://github.com/ddd503/CircleSpreadButton