LoginSignup
0
0

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

Posted at

はじめに

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

  1. 十字ボタン <その1>記事
  2. ジョイスティック <その2>記事
  3. 回転ツマミ(ロータリーエンコーダー)

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

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

RotaryEncoder.gif

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

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

共通コードに変更が入っています。最新をその1から取得してください。

NKRotaryEncoderクラス

NKRotaryEncoderクラスを表示
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
    }
    
}

RotaryEncoderラッパー

RotaryEncoderラッパーを表示
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()
    }
}

使用例

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

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で指定する。

オプション
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)

デモ動画

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


よかったら使ってみてください。
以上
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0