iOS向けレガシーコントローラ（その3、ロータリーエンコーダー）

# はじめに

iOSデバイスをPCのコントローラとして使うため、以下の3つのレガシーコントローラViewを作りました。
_数年前に作ったものですが、新たにSwiftUI向けのラッパーを作りましたので、今回まとめておきます。_


1. 十字ボタン　[<その1>記事](https://qiita.com/nak435/items/89d18abb68ba1970142c)
2. ジョイスティック　[<その2>記事](https://qiita.com/nak435/items/023678c72514891e9766)
3. 回転ツマミ（ロータリーエンコーダー）

今回は、その3として、**ロータリーエンコーダー**です。

右に回すとカウントアップ、左に回すとカウントダウンで、回転数に制限はない。

<img width="215" alt="RotaryEncoder.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/4abfba35-a3d2-5b81-93f6-87cd3c483242.gif" />



# ロータリーエンコーダーコントロール

Storyboardで開発する場合は、下記のNKRotaryEncoderクラスを使用する。SwiftUIで使用する場合は、この後のRotaryEncoderラッパーを使用する。

_共通コードに変更が入っています。最新を[その1](https://qiita.com/nak435/items/89d18abb68ba1970142c#共通コード)から取得してください。_

### NKRotaryEncoderクラス

<details><summary>NKRotaryEncoderクラスを表示</summary><div>

```swift:NKJoystick.swift
//
//  NKRotaryEncoder.swift
//

import UIKit

@objc public protocol NKRotaryEncoderDelegate {
    @objc optional func rotaryEncoderWillBeginTracking(_ rotaryEncoder: NKRotaryEncoder)
    @objc optional func rotaryEncoderDidEndTracking(_ rotaryEncoder: NKRotaryEncoder)
    @objc optional func rotaryEncoderWillValueChanged(_ rotaryEncoder: NKRotaryEncoder)
    @objc optional func rotaryEncoderDidValueChanged(_ rotaryEncoder: NKRotaryEncoder)
}

@IBDesignable
public class NKRotaryEncoder: UIControl {

    public enum NKRotaryEncoderDirection {
        case clockwise, counterclockwise
    }

    fileprivate var currentLocation: CGFloat = .zero
    fileprivate var previousLocation: CGFloat = .zero
    fileprivate var clickCountPerAround: Int = .zero
    fileprivate var lap: Int = .zero
    
    fileprivate var locationOffset: CGFloat = .zero // needle position vs touch position
    
    fileprivate var captionLabel: UILabel!
    fileprivate var valueLabel: UILabel!

    var previousCount: CGFloat = .zero
    var count: CGFloat = .zero {
        didSet {
            setNeedsDisplay()
            valueLabel.text = String(format: "%.0f", count)
        }
    }
    var clickCount: Int {
        get { clickCountPerAround }
        set {
            if clickCountPerAround == newValue { return }
            clickCountPerAround = newValue
            reset()
        }
    }
    var caption: String? {
        get { captionLabel.text ?? nil }
        set {
            if captionLabel == nil {
                captionLabel = UILabel(frame: bounds).apply {
                    $0.numberOfLines = 0
                    $0.textAlignment = .center
                    self.addSubview($0)
                }
            }
            captionLabel?.text = newValue
        }
    }
    
    var direction: NKRotaryEncoderDirection = .clockwise
    
    @IBInspectable public var needShowValue: Bool = true {
        didSet {
            valueLabel?.isHidden = !needShowValue
        }
    }
    @IBInspectable public var needDrawCenter: Bool = true
    @IBInspectable public var needleLength: CGFloat = 50
    @IBOutlet public weak var delegate: NKRotaryEncoderDelegate?
    
    public override func layoutSubviews() {
        super.layoutSubviews()
        if valueLabel.frame.height == 0 || valueLabel.frame.width == 0 {
            let rect = CGRect(x: bounds.origin.x, y: bounds.size.height - 20, width: bounds.width, height: 10)
            valueLabel.frame = rect
        }
        if let captionLabel = captionLabel {
            if captionLabel.frame.height == 0 || captionLabel.frame.width == 0 {
                captionLabel.frame = bounds
            }
        }
    }

    init(frame: CGRect, clicks: Int = 60) {
        super.init(frame: frame)
        backgroundColor = .systemBackground
        clickCountPerAround = clicks
        captionLabel = nil
        
        let rect = CGRect(x: bounds.origin.x, y: bounds.size.height - 20, width: bounds.width, height: 10)
        valueLabel = UILabel(frame: rect).apply {
            $0.font = .systemFont(ofSize: UIFont.smallSystemFontSize)
            $0.textAlignment = .center
            $0.isHidden = !self.needShowValue
            $0.text = "0"
            addSubview($0)
        }
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func reset() {
        currentLocation = .zero
        previousLocation = .zero
        lap = .zero
        count = .zero
        direction = .clockwise
        locationOffset = .zero
        previousCount = .zero
      //tick()
    }
    override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        let location = touch.location(in: self)
        checkLocation(location, begin: true)
        delegate?.rotaryEncoderWillBeginTracking?(self)
        return true
    }
    
    override public func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        let oldCount = count
        delegate?.rotaryEncoderWillValueChanged?(self)
        let location = touch.location(in: self)
        checkLocation(location)
        if oldCount != count {
            delegate?.rotaryEncoderDidValueChanged?(self)
        }
        return true
    }
    
    override public func endTracking(_ touch: UITouch?, with event: UIEvent?) {
        currentLocation -= locationOffset
        locationOffset = .zero
        delegate?.rotaryEncoderDidEndTracking?(self)
    }
    
    override public func draw(_ rect: CGRect) {
        super.draw(rect)
                
        let pi2 = CGFloat.pi * 2
        let needleLocation = currentLocation - locationOffset
//      let myCenter = CGPoint(x: bounds.midY, y: bounds.midX)
        
        if needDrawCenter {
            UIBezierPath(circleCenter: center, radius: 2).apply {
                UIColor.label.setFill()
                $0.fill()
            }
        }

        var radius = min(bounds.size.width, bounds.size.height) / 2 - 2
        UIBezierPath(circleCenter: center, radius: radius).apply {
            UIColor.label.setStroke()
            $0.lineWidth = 2
            $0.stroke()
        }
        
        radius += 1
        let x0 = center.x + radius * sin((2 * .pi) * needleLocation)
        let y0 = center.y - radius * cos((2 * .pi) * needleLocation)
        let x1 = center.x + (radius - needleLength) * sin(pi2 * needleLocation)
        let y1 = center.y - (radius - needleLength) * cos(pi2 * needleLocation)
        UIBezierPath(linePoint: CGPoint(x: x0, y: y0), to: CGPoint(x: x1, y: y1)).apply {
            UIColor.label.setStroke()
            $0.lineWidth = 5
            $0.stroke()
        }

        let x2 = center.x + (radius - needleLength + 2) * sin(pi2 * needleLocation)
        let y2 = center.y - (radius - needleLength + 2) * cos(pi2 * needleLocation)
        UIBezierPath(linePoint: CGPoint(x: x0, y: y0), to: CGPoint(x: x2, y: y2)).apply {
            UIColor.red.setStroke()
            $0.lineWidth = 1.5
            $0.stroke()
        }
    }
}

private extension NKRotaryEncoder {
    func checkLocation(_ location: CGPoint, begin: Bool = false) {
        let newLocation = degreesCW(location)

        let diff = newLocation - currentLocation
        if begin { locationOffset = diff }

        let diff2 = currentLocation - previousLocation
        if abs(diff) < 0.001 { return }
        
        previousLocation = currentLocation
        currentLocation = newLocation
        let needleLocation = currentLocation - locationOffset

        let lastCount = count
        
        if abs(diff) > 0.5 {
            if diff2 > 0 && direction == .clockwise { direction = .clockwise; lap += 1 }
            if diff2 < 0 && direction == .counterclockwise { direction = .counterclockwise; lap -= 1 }
            let d = floor(CGFloat(clickCountPerAround) * needleLocation)
            count = CGFloat(lap * clickCountPerAround) + d
            
        } else {
            if diff > 0 { direction = .clockwise }
            if diff < 0 { direction = .counterclockwise }
            let d = floor(CGFloat(clickCountPerAround) * needleLocation)
            count = CGFloat(lap * clickCountPerAround) + d
        }

        if count != lastCount { tick() }
    }
    func degreesCW(_ point: CGPoint) -> CGFloat {
        var r = atan2(point.y - bounds.midY, point.x - bounds.midX)
        r += .pi / 2
        if r < 0 { r += 2 * .pi }
        r /= (2 * .pi)
        return r
    }
    
}
```

</div></details>

### RotaryEncoderラッパー

<details><summary>RotaryEncoderラッパーを表示</summary><div>

```swift:Joystick.swift
//
//  RotaryEncoder.swift
//
//      Warpper for NKRotaryEncoder
//

import SwiftUI

struct RotaryEncoder: UIViewRepresentable {
    
    enum State {
        case began, moved, ended
    }

    typealias Direction = NKRotaryEncoder.NKRotaryEncoderDirection
    
    private var needleLength: CGFloat? = nil
    private var caption: String? = nil
    private var clickCountPerAround: Int? = nil
    private var needShowValue: Bool? = nil
    private var needDrawCenter: Bool? = nil

    typealias Callback = (State, Direction, _ clickCount: Int) -> Void
    private var onChange: Callback?
    
    let setViewHandler: ((NKRotaryEncoder) -> Void)?
    init(_ setViewHandler: ((NKRotaryEncoder) -> Void)? = nil) {
        self.setViewHandler = setViewHandler
    }
    func makeUIView(context: Context) -> NKRotaryEncoder {
        let view = NKRotaryEncoder(frame: CGRect.zero)
        setViewHandler?(view)
        view.delegate = context.coordinator
        return view
    }
    
    func updateUIView(_ rotaryEncoder: NKRotaryEncoder, context: Context) {
        if let knobSize = needleLength { rotaryEncoder.needleLength = knobSize }
        if let caption = caption { rotaryEncoder.caption = caption }
        if let clickCountPerAround = clickCountPerAround { rotaryEncoder.clickCount = clickCountPerAround }
        if let needShowValue = needShowValue { rotaryEncoder.needShowValue = needShowValue }
        if let needCenterDraw = needDrawCenter { rotaryEncoder.needDrawCenter = needCenterDraw }

        context.coordinator.onChangeCallback = onChange
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func onChange(callback: @escaping Callback) -> Self {
        var view = self
        view.onChange = callback
        return view
    }
    
    class Coordinator: NSObject, NKRotaryEncoderDelegate {
        let parent: RotaryEncoder
        var onChangeCallback: Callback?
        
        init(_ nkRotaryEncoder: RotaryEncoder) {
            parent = nkRotaryEncoder
        }
        
        func rotaryEncoderWillBeginTracking(_ rotaryEncoder: NKRotaryEncoder) {
            onChangeCallback?(State.began, rotaryEncoder.direction, rotaryEncoder.count.int)
        }
        func rotaryEncoderDidEndTracking(_ rotaryEncoder: NKRotaryEncoder) {
            onChangeCallback?(State.ended, rotaryEncoder.direction, rotaryEncoder.count.int)
        }
        func rotaryEncoderWillValueChanged(_ rotaryEncoder: NKRotaryEncoder) {
        }
        func rotaryEncoderDidValueChanged(_ rotaryEncoder: NKRotaryEncoder) {
            onChangeCallback?(State.moved, rotaryEncoder.direction, rotaryEncoder.count.int)
        }
    }
}

extension RotaryEncoder {
    func needleLength(_ size: CGFloat) -> Self {
        var view = self
        view.needleLength = size
        return view
    }
    func caption(_ caption: String) -> Self {
        var view = self
        view.caption = caption
        return view
    }
    func clickCountPerAround(_ clickCountPerAround: Int) -> Self {
        var view = self
        view.clickCountPerAround = clickCountPerAround
        return view
    }
    func needShowValue(_ need: Bool) -> Self {
        var view = self
        view.needShowValue = need
        return view
    }
    func needDrawCenter(_ need: Bool) -> Self {
        var view = self
        view.needDrawCenter = need
        return view
    }
}

class RotaryEncoderViewModel: ObservableObject {
    private weak var rotaryEncoder: NKRotaryEncoder?
    
    func setRotaryEncoder(_ rotaryEncoder: NKRotaryEncoder) {
        self.rotaryEncoder = rotaryEncoder
    }
    
    func reset() {
        rotaryEncoder?.reset()
    }
}
```

</div></details>


# 使用例

SwiftUIでの使用例を以下に示す。Previewでもちゃんと動作します。

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    @StateObject var viewModel = RotaryEncoderViewModel()

    @State private var state: RotaryEncoder.State = .ended
    @State private var dir: RotaryEncoder.Direction = .clockwise
    @State private var count: Int = 0
    var body: some View {
        VStack {
            RotaryEncoder { rotaryEncoder in
                viewModel.setRotaryEncoder(rotaryEncoder)
            }
            .needleLength(50)
//            .caption("DEMO")
//            .needDrawCenter(false)
            .clickCountPerAround(24)
//            .needShowValue(false)
            .onChange { state, dir, count in
                self.state = state
                self.dir = dir
                self.count = count
            }
            .frame(width: 200, height: 200)
            let text = "state: \(state)\ndir: \(dir)\ncount: \(count)"
            Text(text)
            Button("Reset", action: {
                viewModel.reset()
            })
            .buttonStyle(.bordered)
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```

以下のオプションを用意しており、view modifierで指定する。

```swift:オプション
RotaryEncoder()

	//つまみにつける針(赤のライン)の長さ。デフォルトは50ドット
	.needleLength(50)

	//コントロールの中央に表示する見出し
	.caption("DEMO") 

	//つまみ一周のクリック数。デフォルトは60クリック
	.clickCountPerAround(24)

	//値ラベルを表示しない場合はfalse。デフォルトはtrue
	.needShowValue(false)

	//つまみの中心点を表示しない場合はfalse。デフォルトはtrue
	//（中心点はcaption表示と重なる）
	.needDrawCenter(false)

	//イベントハンドラ
	//state: タップ状態（began, moved, ended）、
	//dir: 回転方向（clockwise, counterclockwise）、
	//clickCount: 現在のクリック数
	.onChange { state, dir, clickCount in <code> }

	//サイズを必ず指定して正方形とすること
	.frame(width:200, height: 200)
```


# デモ動画

前項の使用例のコードを実行させた動画
<p>

<iframe width="214" height="446" src="https://www.youtube.com/embed/qU4E9syMKJU?rel=0" title="iOS向けレガシーコントローラ（その3、ロータリーエンコーダー）" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

</p>
<hr>
よかったら使ってみてください。<br>
以上