こんな感じの、アニメーションで開閉するメニューボタンを実装していきます。
実装のイメージとしては、一つ一つのボタンを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)
}
}
}
以上です。
ココナラというサービスをご存知ですか?
ココナラは、プログラミングやウェブ制作、デザインなどの専門知識を持つ人たちが、自分のスキルを活かしてサービスを提供する場所です。
初学者の方でも気軽に相談できるため、自分のスキルアップにも最適です。また、自分自身もココナラでサービスを提供することができ、収入を得ることができます。
ぜひ、ココナラに会員登録して、新しい世界を体験してみましょう!