0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-06-23

はじめに

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

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

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

joystick.gif

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

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
    ・ジョイスティックを傾けて維持した場合に、リピートイベントするオプションを追加
0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?