こんな感じの、アニメーションで開閉するメニューボタンを実装していきます。
実装のイメージとしては、一つ一つのボタンをMenuButtonとして独自クラスを作成し、それらをまとめてExpandButtonとして、UIViewControllerに配置して使い回すようにしています。
アニメーション等の動作は全てMenuButtonとExpandButtonクラスに任せて、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 / touchesEnded をoverrideし、その中で、必要な処理を記述します。今回は、touchStartAnimation / touchEndAnimationという関数を実装し、その中でアニメーションを実装するようにしました。
CGAffineTransformを2つ以上重ねる
拡大や縮小・回転などは、UIView.transformにCGAffineTransformを設定することで表現することができます。ただ、拡大と回転など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を継承した独自クラスを作成していきます。そしてこのExpandButtonをUIViewControllerに設置して使いまわせるようにします。
ExpandButtonには、actionButtonsとmenuButtonというプロパティを持たせています。どちらも、中身は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 = falseとalpha = 0を設定しておきます。(isHidden = trueも実装しても良い)。そして、isExpandedが切り替わって表示されるタイミングで、isEnabled = true、alpha = 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)
}
}
}
以上です。
😟