はじめに
iOSデバイスをPCのコントローラとして使うため、以下の3つのレガシーコントローラViewを作りました。
数年前に作ったものですが、新たにSwiftUI向けのラッパーを作りましたので、今回まとめておきます。
- 十字ボタン <その1>記事
- ジョイスティック
- 回転ツマミ(ロータリーエンコーダー)
今回は、その2として、ジョイスティックです。

ジョイスティックコントロール
Storyboardで開発する場合は、下記のNKJoystickクラスを使用する。SwiftUIで使用する場合は、この後のFourDirectionsラッパーを使用する。
共通コードに変更が入っています。最新をその1から取得してください。
NKJoystickクラス
NKJoystickクラスを表示
NKJoystick.swift
//
// NKJoystick.swift
// Controller_test01
//
// Created by 中嶋 義弘 on 2021/10/13.
//
import UIKit
@objc public protocol joystickDelegate {
@objc optional func joystickBeginTracking(_ joystick: NKJoystick)
@objc optional func joystickEndTracking(_ joystick: NKJoystick)
@objc optional func joystickWillPositionChanged(_ joystick: NKJoystick)
@objc optional func joystickDidPositionChanged(_ joystick: NKJoystick)
@objc optional func joystickRepeatTracking(_ joystick: NKJoystick)
}
typealias NKJoystickPosition = CGPoint
@IBDesignable
public class NKJoystick: UIControl {
@IBInspectable public var isHold: Bool = false
@IBInspectable public var isRepeatable: Bool = false
@IBInspectable public var repeatDelay: TimeInterval = .zero
@IBInspectable public var repeatInterval: TimeInterval = .zero
@IBOutlet public weak var delegate: joystickDelegate?
fileprivate var radius: CGFloat = .zero
fileprivate var sideLen: CGFloat = .zero
fileprivate var boundsCenter: CGPoint = .zero
fileprivate var currentLocation: CGPoint = .zero //touch location
var previousPosition: NKJoystickPosition = .zero
var position: NKJoystickPosition = .zero {
didSet {
setNeedsDisplay()
}
}
fileprivate var isRepeating: Bool = false
fileprivate var repeatingTimer: Timer?
public override func layoutSubviews() {
super.layoutSubviews()
let side = min(bounds.size.width, bounds.size.height) / 2
radius = side
sideLen = side / sqrt(2)
boundsCenter = CGPoint(x: bounds.midY, y: bounds.midX)
}
override init(frame: CGRect) {
super.init(frame: frame)
position = .zero
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func reset() {
currentLocation = .zero
position = .zero
isSelected = false
tick()
}
override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let location = touch.location(in: self)
setValue(location)
delegate?.joystickBeginTracking?(self)
return true
}
override public func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
delegate?.joystickWillPositionChanged?(self)
let location = touch.location(in: self)
setValue(location)
delegate?.joystickDidPositionChanged?(self)
return true
}
override public func endTracking(_ touch: UITouch?, with event: UIEvent?) {
if isHold { isSelected = true }
if !isSelected {
position = .zero
}
isRepeating = false
delegate?.joystickEndTracking?(self)
}
override public func draw(_ rect: CGRect) {
super.draw(rect)
let radius = self.radius - 8
UIBezierPath(rect: bounds).apply {
UIColor.systemBackground.setFill()
$0.fill()
}
/* draw rectage
UIBezierPath(rect: bounds.insetBy(dx: 8, dy: 8)).apply {
UIColor.label.setStroke()
$0.lineWidth = 2
$0.stroke()
}
*/
UIBezierPath(circleCenter: boundsCenter, radius: radius).apply {
UIColor.label.setStroke()
$0.lineWidth = 2
$0.stroke()
}
let x = bounds.midX
let y0 = bounds.midY - radius
let y1 = bounds.midY + radius
UIBezierPath(linePoint: CGPoint(x: x, y: y0), to: CGPoint(x: x, y: y1)).apply {
UIColor.systemGray.setStroke()
$0.lineWidth = 2
$0.stroke()
}
let y = bounds.midY
let x0 = bounds.midX - radius
let x1 = bounds.midX + radius
UIBezierPath(linePoint: CGPoint(x: x0, y: y), to: CGPoint(x: x1, y: y)).apply {
UIColor.systemGray.setStroke()
$0.lineWidth = 2
$0.stroke()
}
//draw stick
var stickPosition = CGPoint(x: (position.x * radius) + boundsCenter.x, y: -(position.y * radius) + boundsCenter.y)
var radian = atan2(position.x, position.y) //* (180.0 / .pi)
if radian < 0 { radian += .pi * 2 }
let circumferencesLocation = CGPoint(x: sin(radian), y: -cos(radian))
let circumferencesPosition = circumferencesLocation * radius + boundsCenter
if position.distance > circumferencesLocation.distance {
stickPosition = circumferencesPosition
}
UIBezierPath(linePoint: boundsCenter, to: stickPosition).apply {
UIColor.red.setStroke()
$0.lineWidth = 2
$0.stroke()
}
UIBezierPath(circleCenter: stickPosition, radius: 6).apply {
UIColor.red.setFill()
$0.fill()
}
}
}
private extension NKJoystick {
func setValue(_ location: CGPoint) {
previousPosition = position
currentLocation = location
var x = currentLocation.x - boundsCenter.x
var y = currentLocation.y - boundsCenter.y
if x < -radius { x = -radius }
if radius < x { x = radius }
if y < -radius { y = -radius }
if radius < y { y = radius }
x = floor(1000 * x / radius) / 1000
y = floor(1000 * y / radius) / 1000
position = NKJoystickPosition(x: x, y: -y)
if position == previousPosition && !isRepeatable { return }
tick()
if isRepeatable {
if !isRepeating {
isRepeating = true
repeatingTimer = Timer.scheduledTimer(withTimeInterval: repeatDelay, repeats: false, block: { self.timeout($0) })
}
}
}
func timeout(_ timer: Timer?) {
if !self.isRepeatable || !self.isRepeating {
timer?.invalidate()
repeatingTimer = nil
return
}
tick()
self.delegate?.joystickRepeatTracking?(self)
if let timer = repeatingTimer { timer.invalidate() }
repeatingTimer = Timer.scheduledTimer(withTimeInterval: repeatInterval, repeats: false, block: { self.timeout($0) })
}
}
Joystickラッパー
Joystickラッパーを表示
Joystick.swift
//
// Joystick.swift
//
// Wrapper for NKJoystick
//
import SwiftUI
struct Joystick: UIViewRepresentable {
enum State {
case began, moved, ended, repeating
}
typealias Position = CGPoint
private var isHold: Bool? = nil
private var isRepeatable: Bool? = nil
private var repeatDelay: TimeInterval? = nil
private var repeatInterval: TimeInterval? = nil
typealias Callback = (State, Position) -> Void
private var onChange: Callback?
let setViewHandler: ((NKJoystick) -> Void)?
init(_ setViewHandler: ((NKJoystick) -> Void)? = nil) {
self.setViewHandler = setViewHandler
//isHold = true
}
func makeUIView(context: Context) -> NKJoystick {
let view = NKJoystick(frame: CGRect.zero)
setViewHandler?(view)
view.delegate = context.coordinator
return view
}
func updateUIView(_ joystick: NKJoystick, context: Context) {
if let isHold = isHold { joystick.isHold = isHold }
if let isRepeatable = isRepeatable { joystick.isRepeatable = isRepeatable }
if let repeatDelay = repeatDelay { joystick.repeatDelay = repeatDelay }
if let repeatInterval = repeatInterval { joystick.repeatInterval = repeatInterval }
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, joystickDelegate {
let parent: Joystick
var onChangeCallback: Callback?
init(_ nkJoystick: Joystick) {
parent = nkJoystick
}
func joystickBeginTracking(_ joystick: NKJoystick) {
onChangeCallback?(.began, joystick.position)
}
func joystickEndTracking(_ joystick: NKJoystick) {
onChangeCallback?(.ended, joystick.position)
}
func joystickWillPositionChanged(_ joystick: NKJoystick) {
}
func joystickDidPositionChanged(_ joystick: NKJoystick) {
onChangeCallback?(.moved, joystick.position)
}
func joystickRepeatTracking(_ joystick: NKJoystick) {
onChangeCallback?(.repeating, joystick.position)
}
}
}
extension Joystick {
func hold(_ hold: Bool) -> Self {
var view = self
view.isHold = hold
return view
}
func repeatable(_ repeatable: Bool, interval: TimeInterval = 0.2, delay: TimeInterval = 1.0) -> Self {
var view = self
view.isRepeatable = repeatable
view.repeatDelay = delay
view.repeatInterval = interval
return view
}
}
class JoystickViewModel: ObservableObject {
private weak var joystick: NKJoystick?
func setJoystick(_ joystick: NKJoystick) {
self.joystick = joystick
}
func reset() {
joystick?.reset()
}
}
使用例
SwiftUIでの使用例を以下に示す。Previewでもちゃんと動作します。
ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject var viewModel = JoystickViewModel()
@State private var reset: Bool = false
@State var state: Joystick.State = .ended
@State var position: Joystick.Position = .origin
var body: some View {
VStack {
Joystick()
.onChange { state, value in
self.state = state
self.position = value
}
.frame(width:200, height: 200)
let text = "state: \(state), x: \(position.x, specifier: "%.3f"), y:\(position.y, specifier: "%.3f")"
Text(text)
Joystick { joystick in
viewModel.setJoystick(joystick)
}
.hold(true)
.repeatable(true)
.onChange { state, value in
self.state = state
self.position = value
}
.frame(width:200, height: 200)
Button("Reset") {
viewModel.reset()
position = .origin
}
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
以下のオプションを用意しており、view modifierで指定する。
オプション
Joystick()
//指を離してもスティック位置を維持させる場合にtrue
//viewModelのreset()でニュートラルに戻せる
.hold(true)
//ジョイスティックを傾けて維持した時に自動リピートする場合にtrue。
//リピート開始までの遅延時間(1秒)とリピート間隔(0.2秒)を指定できる(カッコ内はデフォルト値)
.repeatable(true, interval: 0.2, delay: 1.0)
//イベントハンドラ
//state: タップ状態(began, moved, ended, repeating)、position: スティック位置
.onChange { state, position in <code> }
//サイズを必ず指定して正方形とすること
.frame(width:200, height: 200)
- スティック位置
\ | 最小 | ニュートラル | 最大 |
---|---|---|---|
X軸 | 左(-1.0) | 中央(0.0) | 右(1.0) |
Y軸 | 下(-1.0) | 中央(0.0) | 上(1.0) |
デモ動画
前項の使用例のコードを実行させた動画
よかったら使ってみてください。
以上
改訂履歴
- 改訂1 2023.6.25
・ジョイスティックを傾けて維持した場合に、リピートイベントするオプションを追加