プログラミング

【Swift】アニメーションメニューボタンの実装 パターン1

expand-button1

こんな感じの、アニメーションで開閉するメニューボタンを実装していきます。

実装のイメージとしては、一つ一つのボタンをMenuButtonとして独自クラスを作成し、それらをまとめてExpandButtonとして、UIViewControllerに配置して使い回すようにしています。

アニメーション等の動作は全てMenuButtonExpandButtonクラスに任せて、UIViewControllerでは、それぞれのボタンをタップしたときのアクションを設定して使いやすくしています。

MenuButtonの実装

MenuButtonのイニシャライザ

まずはMenuButtonのイニシャライザです。イニシャライザでは、CGRect / UIImage / UIColor / actionを引数として受け取るようにしています。

MenuButtonがタップされたときのアクションを設定する

MenuButtonがタップされたときのアクションを設定出来るように、typealias ActionBlock = () -> ()を定義しておきます。

UIButtonのisSelectedを活用する

UIButtonには、isSelected: Boolというプロパティがもともと実装されています。これをoverrideして、isSelectedの値が変わったときに、UIを変更する処理を追加しておきます。

override var isSelected: Bool {
    didSet {
        updateViews()
    }
}

これで、isSelectedの値が変更されたタイミングで、updateViews()が呼ばれるようになります。この中開閉の際の回転を実装するために、UIView.animate内でビューを更新するようにしています。

private func updateViews() {
    UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.75, initialSpringVelocity: 1.0, options: .curveEaseInOut) {
        switch self.isSelected {
            case true:
                // trueのときの処理
            case false:
                // falseのときの処理
          }
        }
}

タップされたとき、指が離れたときのアニメーション

よりボタン感を出すために、タップされたとき・指が離れたときのアニメーションを実装していきます。
タップされたとき・指が離れたときは、それぞれtouchesBegan / touchesEndedoverrideし、その中で、必要な処理を記述します。今回は、touchStartAnimation / touchEndAnimationという関数を実装し、その中でアニメーションを実装するようにしました。

CGAffineTransformを2つ以上重ねる

拡大や縮小・回転などは、UIView.transformCGAffineTransformを設定することで表現することができます。ただ、拡大と回転など2つ以上組み合わせたい場合は、既に設定しているtransformを取得し、そこに、.scaleByなどを設定するやり方が一番簡単かと思います。

CGAffineTransformでつけた変化は、UIView.transform.identityを設定し直すことでリセットができますが、これでは全ての変化が元に戻ってしまいますので、複数組み合わせる場合は注意が必要です。

今回のケースでは、

  • タップしたときに0.95倍にスケールする
  • 指が離れたときに元の大きさする
  • タップしたときに、45度回転して、+xにする
    というアニメーションを組み合わせたかったので、以下のような実装になりました。

MenuButtonの全コード

import UIKit

typealias ActionBlock = () -> ()

class MenuButton: UIButton {

    let baseColor: UIColor
    var tapActionBlock: ActionBlock?

    init(frame: CGRect, image: UIImage, baseColor: UIColor, action: ActionBlock?) {
        self.baseColor = baseColor
        self.tapActionBlock = action
        super.init(frame: frame)

        layer.cornerRadius = frame.width / 2
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.2
        layer.shadowRadius = 3.0
        layer.shadowOffset = .zero

        setImage(image, for: .normal)
        setImage(image, for: .highlighted)

        self.isSelected = false
    }

    override var isSelected: Bool {
        didSet {
            updateViews()
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension MenuButton {
    private func updateViews() {
        UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.75, initialSpringVelocity: 1.0, options: .curveEaseInOut) {
            switch self.isSelected {
            case true:
                self.tintColor = self.baseColor
                self.backgroundColor = .white
                let angle: CGFloat = 45 * CGFloat.pi / 180
                self.transform = CGAffineTransform(rotationAngle: angle)
            case false:
                self.tintColor = .white
                self.backgroundColor = self.baseColor
                self.transform = .identity
            }
        }
    }
}

extension MenuButton {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        touchStartAnimation()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        touchEndAnimation()
    }

    private func touchStartAnimation() {
        UIView.animate(withDuration: 0.05, animations: { [weak self] in
            guard let strongSelf = self else { return }
            strongSelf.transform = strongSelf.transform.scaledBy(x: 0.95, y: 0.95)
            strongSelf.alpha = 0.9
        })
    }

    private func touchEndAnimation() {
        UIView.animate(withDuration: 0.2, animations: { [weak self] in
            guard let strongSelf = self else { return }
            strongSelf.transform = strongSelf.transform.scaledBy(x: 1.052631579, y: 1.052631579)
            strongSelf.alpha = 1.0
        })
    }
}

ExpandButtonの実装

次に先程作成したMenuButtonを使って、ExpandButtonというUIViewを継承した独自クラスを作成していきます。そしてこのExpandButtonUIViewControllerに設置して使いまわせるようにします。

ExpandButtonには、actionButtonsmenuButtonというプロパティを持たせています。どちらも、中身はMenuButtonなのですが、menuButtonには、ボタンの開閉のみを行わせ、実際メニューボタンはactionButtonsに格納するようにしています。

名前もう少しちゃんと考えて作ればよかったかも。。

ExpandButtonのイニシャライザ

ExpandButtonのイニシャライザでは、ActionButtonModelの配列を受け取り、actionButtonsをインスタンス化していきます。

ActionButtonModelはシンプルにボタンを構成する最低限の要素を定義するstructです。

struct ActionButtonModel {
    let sfsymbolsName: String
    let action: ActionBlock
}

今回は、SFSymbolsを使うようにしているので、そのシンボルネームをStringで、タップされたときのアクションをactionで定義出来るようにしておきます。

開閉を担うmenuButtonがタップされたときの処理

イニシャライザで、menuButtonを作成しましたが、ここで、タップされた時の処理を記述していきます。

button.addTarget(self, action: #selector(toggleButton(_:)), for: .touchUpInside)

タップされたときに、toggoleButton(_:)が呼ばれるようにし、その中で、isExpanded: Boolを切り替えています。そして、isExpandedの値が切り替わるとactionButtonをアニメーションして表示・非表示を切り替えています。

どのようなアニメーションしたいかによるのですが、ポイントとしては、各actionButtonは、最初は、menuButtonと同じ位置の裏側に配置し、isEnabled = falsealpha = 0を設定しておきます。(isHidden = trueも実装しても良い)。そして、isExpandedが切り替わって表示されるタイミングで、isEnabled = truealpha = 1を設定し、ボタンとして使えるようになっています。

それぞれのactionButtonがタップされたときの処理

それぞれのactionButtonがタップされたときの処理は、イニシャライズされたときに設定しています。

button.addTarget(self, action: #selector(tapAcctionButton(_:)), for: .touchUpInside)

actionはタップされたタイミングでtapActionButtonを設定しておき、その中で、ActionButtonModelで定義された関数が実行されるようにしています。

@objc func tapAcctionButton(_ sender: MenuButton) {
    sender.tapActionBlock?()
}

その他、色やサイズなどは決め打ちで設定しています。

ExpandButtonの全コード

import UIKit

final class ExpandButton: UIView {

    let baseColor: UIColor = .init(hex: "ef5285")

    fileprivate var actionButtons: [MenuButton]?
    fileprivate lazy var menuButton: MenuButton = {
        let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .black, scale: .large)
        let image = UIImage(systemName: "plus", withConfiguration: config)!
        let frame = CGRect(x: 0, y: 0, width: 60, height: 60)
        let button = MenuButton(frame: frame, image: image, baseColor: baseColor, action: nil)
        button.addTarget(self, action: #selector(toggleButton(_:)), for: .touchUpInside)
        return button
    }()

    fileprivate var isExpanded: Bool = false {
        didSet {
            updateViews()
        }
    }

    init(actionButtons: [ActionButtonModel]) {
        super.init(frame: .zero)
        addSubview(menuButton)

        self.actionButtons = actionButtons.map {
            let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .black, scale: .large)
            let image = UIImage(systemName: $0.sfsymbolsName, withConfiguration: config)!

            let frame = CGRect(x: 0, y: 0, width: 60, height: 60)
            let button = MenuButton(frame: frame, image: image, baseColor: baseColor, action: $0.action)
            button.addTarget(self, action: #selector(tapAcctionButton(_:)), for: .touchUpInside)

            button.alpha = 0.0
            button.isEnabled = false
            insertSubview(button, at: 0)

            return button
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension ExpandButton {
    @objc func toggleButton(_ sender: MenuButton) {
        isExpanded = !isExpanded
    }

    @objc func tapAcctionButton(_ sender: MenuButton) {
        sender.tapActionBlock?()
    }

    func updateViews() {
        menuButton.isSelected = !menuButton.isSelected

        UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.75, initialSpringVelocity: 1.0, options: .curveEaseInOut) {
            guard let buttons = self.actionButtons else { return }
            switch self.isExpanded {
            case true:
                for (index, button) in buttons.enumerated() {
                    button.alpha = 1.0
                    button.isEnabled = true
                    button.transform = CGAffineTransform(translationX: CGFloat(80*(index+1)), y: 0)
                }

            case false:
                self.actionButtons?.forEach {
                    $0.alpha = 0.0
                    $0.isEnabled = false
                    $0.transform = .identity
                }
            }
        }
    }
}

UIViewControllerでの実装

最後にUIViewControllerで使えるように実装していきます。ポイントというポイントもないのですが、しいてあげるとすれば、UIViewControllerの中で、アクションを定義出来るようにしているところかなと思います。

AutoLayoutをSnapKitで実装する

ここは好みが分かれそうなところではありますが、私個人としてはSnapKitをよく使います。コード量が少なくなり見やすくなるので。使い方も直感的で簡単だし、柔軟にAutoLayoutを更新したり付け替えたり、色々できます。

import UIKit
import SnapKit

class ViewController: UIViewController {

    fileprivate lazy var expandButton: ExpandButton = {
        let button = ExpandButton(actionButtons: [
            ActionButtonModel(sfsymbolsName: "person.fill.badge.plus", action: {
                print("tapped action button 1")
            }),
            ActionButtonModel(sfsymbolsName: "message.fill", action: {
                print("tapped action button 2")
            }),
            ActionButtonModel(sfsymbolsName: "phone.fill", action: {
                print("tapped action button 3")
            }),
        ])
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        view.addSubview(expandButton)
    }

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()

        expandButton.snp.makeConstraints {
            $0.height.equalTo(60)
            $0.leading.equalToSuperview().offset(16)
            $0.trailing.equalToSuperview().offset(-16)
            $0.bottom.equalToSuperview().offset(-32)
        }
    }
}

以上です。