はじめに
iOSデバイスをPCのコントローラとして使うため、以下の3つのレガシーコントローラViewを作りました。
数年前に作ったものですが、新たにSwiftUI向けのラッパーを作りましたので、今回まとめておきます。
今回は、その3として、ロータリーエンコーダーです。
右に回すとカウントアップ、左に回すとカウントダウンで、回転数に制限はない。

ロータリーエンコーダーコントロール
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)
デモ動画
前項の使用例のコードを実行させた動画
よかったら使ってみてください。
以上