iOS向けレガシーコントローラ（その2、ジョイスティック）

# はじめに

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


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

今回は、その2として、**ジョイスティック**です。

<img width="215" alt="joystick.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/624c0c4f-ec44-0ff5-bc75-014c33ee57fc.gif" />



# ジョイスティックコントロール

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

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

### NKJoystickクラス

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

```swift: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) })
    }

}
```

</div></details>

### Joystickラッパー

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

```swift: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()
    }
}
```

</div></details>


# 使用例

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

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

```swift:オプション
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）|


# デモ動画

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

<iframe width="214" height="446" src="https://www.youtube.com/embed/mR0Rvf1Dr5k?rel=0" title="iOS向けレガシーコントローラ（その2、ジョイスティック）" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

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


## 改訂履歴

- 改訂1　2023.6.25 <br>・ジョイスティックを傾けて維持した場合に、リピートイベントするオプションを追加