[swift]フリック入力ボタン

#フリック入力ボタン
フリック入力ボタンを作ったので共有します。

##動作イメージ
以下から動作イメージを確認できます。
 https://youtu.be/8_mnbM1f-_o

##解説コメント付きのコード

```swift:FlickButton.swift
import UIKit

protocol FlickButtonDelegate {
    
    // コンポーネントビューが表示される直前に呼び出されます。
    func flickButton(_ flickButton: FlickButton, componentsWillAppear: [FlickButton.Location: UIView])
    
    // コンポーネントビューが表示された直後に呼び出されます。
    func flickButton(_ flickButton: FlickButton, componentsDidAppear: [FlickButton.Location: UIView])
    
    // フリック入力後に呼び出されます。
    func flickButton(_ flickButton: FlickButton, didFlick activatedView: UIView)
    
    // コンポーネントビューが非表示になる直前に呼び出されます。
    func flickButton(_ flickButton: FlickButton, componentsWillDisappear: [FlickButton.Location: UIView])
    
    // コンポーネントビューが非表示になった直後に呼び出されます。
    func flickButton(_ flickButton: FlickButton, componentsDidDisappear: [FlickButton.Location: UIView])
    
}

// デフォルト実装では何も処理を行いません。
extension FlickButtonDelegate {
    func flickButton(_ flickButton: FlickButton, componentsWillAppear: [FlickButton.Location: UIView]) {}
    func flickButton(_ flickButton: FlickButton, componentsDidAppear: [FlickButton.Location: UIView]) {}
    func flickButton(_ flickButton: FlickButton, didFlick activatedView: UIView) {}
    func flickButton(_ flickButton: FlickButton, componentsWillDisappear: [FlickButton.Location: UIView]) {}
    func flickButton(_ flickButton: FlickButton, componentsDidDisappear: [FlickButton.Location: UIView]) {}
}

class FlickButton: UIButton {
    
    // 各コンポーネントビューの位置を区別する
    enum Location {
        case top
        case bottom
        case left
        case right
    }
    
    //-----------------------------------------------------
    // 各コンポーネントビューです。
    // 設定時にすでにコンポーネントビューが設定されていたら、スーパービューから取り除きます。
    // 設定後にコンポーネントビューを保持する配列にセットします。
    var topView: UIView? {
        willSet {
            guard let topView = topView else {
                return
            }
            topView.removeFromSuperview()
        }
        didSet {
            guard let topView = topView else {
                return
            }
            componentViews[.top] = topView
        }
    }
    
    var bottomView: UIView? {
        willSet {
            guard let bottomView = bottomView else {
                return
            }
            bottomView.removeFromSuperview()
        }
        didSet {
            guard let bottomView = bottomView else {
                return
            }
            componentViews[.bottom] = bottomView
        }
    }
    
    var leftView: UIView? {
        willSet {
            guard let leftView = leftView else {
                return
            }
            leftView.removeFromSuperview()
        }
        didSet {
            guard let leftView = leftView else {
                return
            }
            componentViews[.left] = leftView
        }
    }
    
    var rightView: UIView? {
        willSet {
            guard let rightView = rightView else {
                return
            }
            rightView.removeFromSuperview()
        }
        didSet {
            guard let rightView = rightView else {
                return
            }
            componentViews[.right] = rightView
        }
    }
    //-----------------------------------------------------
    
    // 各コンポーネントビューと自身のデフォルトカラーです。
    // フリックによってアクティブ状態になっていない場合に背景色に設定されます。
    var defaultColor: UIColor? {
        didSet {
            backgroundColor = defaultColor
        }
    }
    
    // フリックによってアクティブになった場合に背景色に設定されます。
    var activeColor: UIColor?
    
    var delegate: FlickButtonDelegate?
    
    //------------------------------------------------------------------------------
    // 各コンポーネントビューと自身とのマージンを設定できます。
    // ここに設定した数値分離れた場所にコンポーネントビューが配置されます。
    // 例) componentMargins = UIEdgeInsets(top: 2, left: 4, bottom: 6, right: 8)
    //
    //                [topView]
    //                    |
    //                    2
    //                    |
    // [leftView]-4-[FlickButton]-8-[rightView]
    //                    |
    //                    6
    //                    |
    //                [bottomView]
    var componentMargins = UIEdgeInsets.zero
    //------------------------------------------------------------------------------
    
    // 設定されているコンポーネントビューを保持します。
    private(set) var componentViews = [Location: UIView]()
    
    // フリック入力によってアクティブになったビューをユーザに分かりやすいように
    // 少しの間表示状態にするために使用します。
    private var timer: Timer?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupGestureRecognizers()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupGestureRecognizers()
    }
    
    // 各GestureRecognizerを設定します。
    // UILongPressGestureRecognizer: ロングプレスによって各コンポーネントビューを表示します。
    // UIPanGestureRecognizer: 上下左右のフリック入力の判定に使用します。
    private func setupGestureRecognizers() {
        let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressGesture(_:)))
        longPressGestureRecognizer.delegate = self
        longPressGestureRecognizer.minimumPressDuration = 0.25
        addGestureRecognizer(longPressGestureRecognizer)
        let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
        panGestureRecognizer.delegate = self
        addGestureRecognizer(panGestureRecognizer)
    }
    
    // 各コンポーネントビューを表示します。
    // componentMarginsに設定された値を考慮して表示位置を決定します。
    private func addComponentViews() {
        delegate?.flickButton(self, componentsWillAppear: componentViews)
        for (location, componentView) in componentViews {
            componentView.backgroundColor = defaultColor
            addSubview(componentView)
            let safeAreaFrame = safeAreaLayoutGuide.layoutFrame
            switch location {
            case .top:
                componentView.frame = CGRect(
                    x: safeAreaFrame.origin.x,
                    y: safeAreaFrame.origin.y - safeAreaFrame.height - componentMargins.top,
                    width: safeAreaFrame.width,
                    height: safeAreaFrame.height
                )
            case .bottom:
                componentView.frame = CGRect(
                    x: safeAreaFrame.origin.x,
                    y: safeAreaFrame.origin.y + safeAreaFrame.height + componentMargins.bottom,
                    width: safeAreaFrame.width,
                    height: safeAreaFrame.height
                )
            case .left:
                componentView.frame = CGRect(
                    x: safeAreaFrame.origin.x - safeAreaFrame.width - componentMargins.left,
                    y: safeAreaFrame.origin.y,
                    width: safeAreaFrame.width,
                    height: safeAreaFrame.height
                )
            case .right:
                componentView.frame = CGRect(
                    x: safeAreaFrame.origin.x + safeAreaFrame.width + componentMargins.right,
                    y: safeAreaFrame.origin.y,
                    width: safeAreaFrame.width,
                    height: safeAreaFrame.height
                )
            }
        }
        delegate?.flickButton(self, componentsDidAppear: componentViews)
    }
    
    // 各コンポーネントビューを非表示にします。
    private func removeComponentViews() {
        delegate?.flickButton(self, componentsWillDisappear: componentViews)
        for componentView in componentViews.values {
            componentView.backgroundColor = defaultColor
            componentView.removeFromSuperview()
        }
        delegate?.flickButton(self, componentsDidDisappear: componentViews)
    }
    
    // フリック入力時に呼び出されます。
    // ユーザの指の位置からどのビューが選択されているかを判定し、背景色を変更します。
    private func handlePanGestureChanged(_ sender: UIPanGestureRecognizer) {
        let location = sender.location(in: self)
        let activatedView = activeView(at: location)
        ([self] + componentViews.values).forEach { componentView in
            componentView.backgroundColor = (activatedView == componentView) ? activeColor : defaultColor
        }
    }
    
    // フリック入力終了時に呼び出されます。
    // デリゲートにフリック入力が終了したことを通知します。
    // フリック入力によってアクティブになったビューはすぐに非表示にするのではなく、ユーザに分かりやすいように0.2秒間表示した状態を保っています。
    private func handlePanGestureEnded(_ sender: UIPanGestureRecognizer) {
        
        let location = sender.location(in: self)
        let activatedView = activeView(at: location)
        delegate?.flickButton(self, didFlick: activatedView)
        
        backgroundColor = defaultColor
        removeComponentViews()
        if activatedView != self {
            addSubview(activatedView)
            activatedView.backgroundColor = activeColor
            timer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(handleRemovingActivatedViewEvent(_:)), userInfo: activatedView, repeats: false)
        }
    }
    
    // フリック入力時のユーザの指の座標から、選択されているビューを判定し返します。
    // どのビューも選択されていなければFlickButton自身が返されます。
    private func activeView(at location: CGPoint) -> UIView {
        let width = safeAreaLayoutGuide.layoutFrame.width
        let height = safeAreaLayoutGuide.layoutFrame.height
        if let topView = topView, location.y < 0 && ((0 <= location.x && location.x <= width) || abs(location.x) <= abs(location.y)) {
            return topView
        } else if let bottomView = bottomView, height < location.y && ((0 <= location.x && location.x <= width) || abs(location.x) <= abs(location.y)) {
            return bottomView
        } else if let leftView = leftView, location.x < 0 && ((0 <= location.y && location.y <= height) || abs(location.y) <= abs(location.x)) {
            return leftView
        } else if let rightView = rightView, width < location.x && ((0 <= location.y && location.y <= height) || abs(location.y) <= abs(location.x)) {
            return rightView
        }
        return self
    }
    
    // フリック入力によってアクティブになり少しの間表示状態を保っていたビューを非表示にします。
    @objc
    private func handleRemovingActivatedViewEvent(_ timer: Timer) {
        guard let activatedView = timer.userInfo as? UIView else {
            return
        }
        activatedView.removeFromSuperview()
    }
    
    // ロングプレスジェスチャーの開始によって各コンポーネントビューを表示します。
    // ロングプレスジェスチャーの終了によって各コンポーネントビューを非表示にします。
    @objc
    private func handleLongPressGesture(_ sender: UILongPressGestureRecognizer) {
        if sender.state == .began {
            addComponentViews()
        } else if sender.state == .ended {
            removeComponentViews()
        }
    }
    
    // パンジェスチャーのコールバックです。
    @objc
    private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
        if sender.state == .began {
            backgroundColor = activeColor
            addComponentViews()
        } else if sender.state == .changed {
            handlePanGestureChanged(sender)
        } else if sender.state == .ended {
            handlePanGestureEnded(sender)
        }
    }
    
}

extension FlickButton: UIGestureRecognizerDelegate {
    
    // UILongPressGestureRecognizerとUIPanGestureRecognizerが同時に認識されるようにしています。
    // 以下の記事を参考にしました。
    // https://qiita.com/ruwatana/items/16997b1b416512c20fb6
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
    
}


```

##使用方法
上記の[動作イメージ動画](https://youtu.be/8_mnbM1f-_o)で使用してるコードを掲載します。

```swift:ViewController.swift
import UIKit

class ViewController: UIViewController {
    
    let label = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .lightGray
        
        let flickButton = FlickButton()
        view.addSubview(flickButton)
        flickButton.delegate = self
        flickButton.defaultColor = .darkGray
        flickButton.activeColor = .cyan
        flickButton.setTitle("CENTER", for: .normal)
        flickButton.setTitleColor(.white, for: .normal)
        flickButton.titleLabel?.font = .boldSystemFont(ofSize: 18)
        flickButton.addTarget(self, action: #selector(handleFlickButtonTouchUpInsideEvent(_:)), for: .touchUpInside)
        flickButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            flickButton.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
            flickButton.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
            flickButton.widthAnchor.constraint(equalToConstant: 128),
            flickButton.heightAnchor.constraint(equalTo: flickButton.widthAnchor),
            ])
        
        let topView = UILabel()
        flickButton.topView = topView
        topView.text = "TOP"
        topView.textColor = .white
        topView.textAlignment = .center
        topView.font = .boldSystemFont(ofSize: 18)
        let bottomView = UILabel()
        flickButton.bottomView = bottomView
        bottomView.text = "BOTTOM"
        bottomView.textColor = .white
        bottomView.textAlignment = .center
        bottomView.font = .boldSystemFont(ofSize: 18)
        let leftView = UILabel()
        flickButton.leftView = leftView
        leftView.text = "LEFT"
        leftView.textColor = .white
        leftView.textAlignment = .center
        leftView.font = .boldSystemFont(ofSize: 18)
        let rightView = UILabel()
        flickButton.rightView = rightView
        rightView.text = "RIGHT"
        rightView.textColor = .white
        rightView.textAlignment = .center
        rightView.font = .boldSystemFont(ofSize: 18)
        
        view.addSubview(label)
        label.backgroundColor = .darkGray
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 24)
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            label.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            label.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            label.heightAnchor.constraint(equalToConstant: 64),
            ])
    }
    
    @objc
    private func handleFlickButtonTouchUpInsideEvent(_ sender: FlickButton) {
        label.text = sender.title(for: .normal)
    }
    
}

extension ViewController: FlickButtonDelegate {
    
    func flickButton(_ flickButton: FlickButton, didFlick activatedView: UIView) {
        if let flickButton = activatedView as? FlickButton {
            label.text = flickButton.title(for: .normal)
        } else {
            label.text = (activatedView as? UILabel)?.text
        }
    }
    
}

```