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

共通コード
レガシーコントローラで共通で使う関数やExtension、その他諸々。
十字キーを押した時や回転ツマミを回した時のクリック感をフィードバックするため、UINotificationFeedbackGeneratorを使用しているが、iPadはバイブレータを内蔵しないから機能しない?
共通コードを表示
//
// Misc.swift
//
import UIKit
import AudioToolbox
var isPad: Bool { UIDevice.current.userInterfaceIdiom == .pad }
public protocol Applicable {}
public extension Applicable {
@discardableResult
func apply(block: (Self) -> Void) -> Self {
block(self)
return self
}
}
extension NSObject: Applicable {}
public extension UIBezierPath {
convenience init(circleCenter center: CGPoint, radius: CGFloat) {
self.init(arcCenter: center, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
}
convenience init(linePoint point: CGPoint, to: CGPoint) {
self.init()
self.move(to: point)
self.addLine(to: to)
}
}
func tick() {
if isPad {
let soundID = SystemSoundID(1104)
AudioServicesPlaySystemSound(soundID)
} else if #available(iOS 10.0, *) {
Feedbacker.impact(style: .light)
} else {
let soundID = SystemSoundID(1519)
AudioServicesPlaySystemSound(soundID)
}
}
struct Feedbacker {
static func notice(type: UINotificationFeedbackGenerator.FeedbackType) {
if #available(iOS 10.0, *) {
let generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(type)
}
}
static func impact(style: UIImpactFeedbackGenerator.FeedbackStyle) {
if #available(iOS 10.0, *) {
let generator = UIImpactFeedbackGenerator(style: style)
generator.prepare()
generator.impactOccurred()
}
}
static func selection() {
if #available(iOS 10.0, *) {
let generator = UISelectionFeedbackGenerator()
generator.prepare()
generator.selectionChanged()
}
}
}
extension CGFloat {
var int: Int { Int(self) }
}
extension Int {
var uint8: UInt8 { UInt8(self & 0xFF) }
var float: CGFloat { CGFloat(self) }
}
extension Character {
var asciiCode: UInt8 { self.asciiValue ?? 0 }
var string: String { String(self) }
}
extension String {
var asciiCode: UInt8 { self.first?.asciiCode ?? 0 }
var asciiCodes: [UInt8] { self.map { $0.asciiCode } }
}
extension CGPoint {
static let origin: CGPoint = .zero
var isOrigin: Bool { self == .zero }
var distance: CGFloat { (self.x * self.x) + (self.y * self.y) }
}
extension String.StringInterpolation {
mutating func appendInterpolation(_ value: CGFloat, specifier: String) {
appendLiteral(String(format: specifier, value))
}
}
func *(_ lhs: (x: CGFloat, y: CGFloat), _ rhs: CGFloat) -> CGPoint {
CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
}
func *(_ lhs: CGPoint, _ rhs: Int) -> CGPoint {
CGPoint(x: lhs.x * rhs.float, y: lhs.y * rhs.float)
}
func *(_ lhs: CGPoint, _ rhs: CGFloat) -> CGPoint {
CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
}
func +(_ lhs: CGPoint, _ rhs: CGPoint) -> CGPoint {
CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
十字ボタンコントロール
Storyboardで開発する場合は、下記のNKFourDirectionsクラスを使用する。SwiftUIで使用する場合は、この後のFourDirectionsラッパーを使用する。
NKFourDirectionsクラス
NKFourDirectionsクラスを表示
//
// NKFourDirections.swift
//
import UIKit
@objc public protocol NKFourDirectionsDelegate {
@objc optional func fourDirectionsTouchDown(_ nkFourDirections: NKFourDirections)
@objc optional func fourDirectionsTouchUp(_ nkFourDirections: NKFourDirections)
@objc optional func fourDirectionsTouchDownRepeat(_ nkFourDirections: NKFourDirections)
}
@IBDesignable
public class NKFourDirections: UIControl {
public enum NKDirection {
case none, north, south, west, east
}
public enum NKEvent {
case none, touchDown, touchUp, touchDownRepeat
}
struct NKDirectionOptions: OptionSet {
let rawValue: Int
static let north = NKDirectionOptions(rawValue: 1 << 0)
static let south = NKDirectionOptions(rawValue: 1 << 1)
static let west = NKDirectionOptions(rawValue: 1 << 2)
static let east = NKDirectionOptions(rawValue: 1 << 3)
static let all: NKDirectionOptions = [.north, .south, .west, .east]
//getter
var description: String {
let mask = [Self.north, .south, .west, .east]
let maskStr = ["north", "south", "west", "east"]
var result = ""
for n in 0 ..< mask.count {
if self.contains(mask[n]) {
result += result.isEmpty ? "[" : ", "
result += maskStr[n]
}
}
result += result.isEmpty ? "[]" : "]"
return result
}
}
private(set) var direction: NKDirection = .none
private(set) var repeatCount: Int = 0
private(set) var lastEvent: NKEvent = .none
private(set) var lastDir: NKDirection = .none
var needDirection: NKDirectionOptions = .all
@IBInspectable public var isHold: Bool = false
@IBInspectable public var isRepeatable: Bool = false
@IBInspectable public var repeatDelay: TimeInterval = .zero
@IBInspectable public var repeatInterval: TimeInterval = .zero
@IBInspectable public var needCrossFace: Bool = true
@IBOutlet public weak var delegate: NKFourDirectionsDelegate?
fileprivate var captionLabel: UILabel!
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
}
}
fileprivate var diagonal: CGFloat = .zero
fileprivate var boundsCenter: CGPoint = .zero
fileprivate var isRepeating: Bool = false
fileprivate var lastDirection: NKDirection = .none
fileprivate var directionView: UIView?
fileprivate var repeatingTimer: Timer?
public override func layoutSubviews() {
super.layoutSubviews()
diagonal = min(bounds.size.width, bounds.size.height) / 2 - 8
captionLabel?.frame = self.frame
boundsCenter = CGPoint(x: bounds.midY, y: bounds.midX)
}
override init(frame: CGRect) {
super.init(frame: frame)
direction = .none
lastEvent = .none
lastDirection = .none
directionView = nil
repeatDelay = 1.0
repeatInterval = 0.2
repeatingTimer = nil
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func reset() {
direction = .none
isRepeating = false
lastEvent = .none
lastDirection = .none
directionView?.removeFromSuperview()
directionView = nil
repeatingTimer = nil
isSelected = false
tick()
}
override public func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let location = touch.location(in: self)
if !setValue(location, event: .touchDown) { return true }
if isHold {
if isSelected && lastDir == direction {
isSelected = false
tick()
} else {
isSelected = true
}
}
lastDir = direction
delegate?.fourDirectionsTouchDown?(self)
return true
}
override public func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
lastDir = direction
let location = touch.location(in: self)
if setValue(location, event: .touchDown) {
delegate?.fourDirectionsTouchDown?(self)
}
return true
}
override public func endTracking(_ touch: UITouch?, with event: UIEvent?) {
lastDir = direction
if let touch = touch {
let location = touch.location(in: self)
setValue(location, event: .touchUp)
}
lastEvent = .touchUp
if !isSelected {
delegate?.fourDirectionsTouchUp?(self)
isRepeating = false
direction = .none
directionView?.removeFromSuperview()
directionView = nil
repeatingTimer?.invalidate()
repeatingTimer = nil
}
}
override public func draw(_ rect: CGRect) {
super.draw(rect)
UIBezierPath(rect: bounds).apply {
UIColor.systemBackground.setFill()
$0.fill()
}
if needCrossFace {
//draw cross
let t31 = bounds.width / 3
let t32 = bounds.width * 2 / 3
let rect = bounds.insetBy(dx: 2, dy: 2)
UIBezierPath().apply {
let r: CGFloat = 4
let c3: CGFloat = 0, c6: CGFloat = .pi / 2, c9: CGFloat = .pi, c12: CGFloat = .pi + c6
$0.move(to: CGPoint(x: t31, y: rect.origin.y + r))
$0.addArc(withCenter: CGPoint(x: t31 + r, y: rect.origin.y + r), radius: r, startAngle: c9, endAngle: c12 , clockwise: true) //1
$0.addLine(to: CGPoint(x: t32 - r, y: rect.origin.y))
$0.addArc(withCenter: CGPoint(x: t32 - r, y: rect.origin.y + r), radius: r, startAngle: c12, endAngle: c3, clockwise: true) //2
$0.addLine(to: CGPoint(x: t32, y: t31 - r))
$0.addArc(withCenter: CGPoint(x: t32 + r, y: t31 - r), radius: r, startAngle: c9, endAngle: c6, clockwise: false) //3
$0.addLine(to: CGPoint(x: rect.size.width - r, y: t31))
$0.addArc(withCenter: CGPoint(x: rect.size.width - r, y: t31 + r), radius: r, startAngle: c12, endAngle: c3, clockwise: true) //4
$0.addLine(to: CGPoint(x: rect.size.width, y: t32 - r))
$0.addArc(withCenter: CGPoint(x: rect.size.width - r, y: t32 - r), radius: r, startAngle: c3, endAngle: c6, clockwise: true) //5
$0.addLine(to: CGPoint(x: t32 + r, y: t32))
$0.addArc(withCenter: CGPoint(x: t32 + r, y: t32 + r), radius: r, startAngle: c12, endAngle: c9, clockwise: false) //6
$0.addLine(to: CGPoint(x: t32, y: rect.size.height - r))
$0.addArc(withCenter: CGPoint(x: t32 - r, y: rect.size.height - r), radius: r, startAngle: c3, endAngle: c6, clockwise: true) //7
$0.addLine(to: CGPoint(x: t31 + r, y: rect.size.height))
$0.addArc(withCenter: CGPoint(x: t31 + r, y: rect.size.height - r), radius: r, startAngle: c6, endAngle: c9, clockwise: true) //8
$0.addLine(to: CGPoint(x: t31, y: t32 + r))
$0.addArc(withCenter: CGPoint(x: t31 - r, y: t32 + r), radius: r, startAngle: c3, endAngle: c12, clockwise: false) //9
$0.addLine(to: CGPoint(x: rect.origin.x + r, y: t32))
$0.addArc(withCenter: CGPoint(x: rect.origin.x + r, y: t32 - r), radius: r, startAngle: c6, endAngle: c9, clockwise: true) //10
$0.addLine(to: CGPoint(x: rect.origin.x, y: t31 + r))
$0.addArc(withCenter: CGPoint(x: rect.origin.x + r, y: t31 + r), radius: r, startAngle: c9, endAngle: c12, clockwise: true) //11
$0.addLine(to: CGPoint(x: t31 - r, y: t31))
$0.addArc(withCenter: CGPoint(x: t31 - r, y: t31 - r), radius: r, startAngle: c6, endAngle: c3, clockwise: false) //12
$0.addLine(to: CGPoint(x: t31, y: rect.origin.y + r))
UIColor.label.setStroke()
$0.lineWidth = 2
$0.stroke()
}
} else {
//draw rectangle
UIBezierPath(rect: bounds.insetBy(dx: 8, dy: 8)).apply {
UIColor.label.setStroke()
$0.lineWidth = 2
$0.stroke()
}
let x0 = bounds.midX - diagonal + 2
let x1 = bounds.midX + diagonal - 2
let y0 = bounds.midY - diagonal + 2
let y1 = bounds.midY + diagonal - 2
UIBezierPath(linePoint: CGPoint(x: x0, y: y0), to: CGPoint(x: x1, y: y1)).apply {
UIColor.systemGray.setStroke()
$0.lineWidth = 2
$0.stroke()
}
UIBezierPath(linePoint: CGPoint(x: x1, y: y0), to: CGPoint(x: x0, y: y1)).apply {
UIColor.systemGray.setStroke()
$0.lineWidth = 2
$0.stroke()
}
}
}
}
private extension NKFourDirections {
func drawDirection() {
tick()
directionView?.removeFromSuperview()
directionView = nil
directionView = UIView(frame: self.bounds).apply {
$0.backgroundColor = .tintColor.withAlphaComponent(0.5)
$0.isUserInteractionEnabled = false
let outline = UIBezierPath()
outline.move(to: boundsCenter)
if needCrossFace {
//cross draw
let t31 = bounds.width / 3
let t32 = bounds.width * 2 / 3
let rect = bounds.insetBy(dx: 2, dy: 2)
let r: CGFloat = 4
let c3: CGFloat = 0, c6: CGFloat = .pi / 2, c9: CGFloat = .pi, c12: CGFloat = .pi + c6
switch direction {
case .none: break
case .north:
outline.addLine(to: CGPoint(x: t31, y: t31))
outline.addArc(withCenter: CGPoint(x: t31 + r, y: rect.origin.y + r), radius: r, startAngle: c9, endAngle: c12 , clockwise: true) //1
outline.addLine(to: CGPoint(x: t32 - r, y: rect.origin.y))
outline.addArc(withCenter: CGPoint(x: t32 - r, y: rect.origin.y + r), radius: r, startAngle: c12, endAngle: c3, clockwise: true) //2
outline.addLine(to: CGPoint(x: t32, y: t31))
case .south:
outline.addLine(to: CGPoint(x: t32, y: t32))
outline.addArc(withCenter: CGPoint(x: t32 - r, y: rect.size.height - r), radius: r, startAngle: c3, endAngle: c6, clockwise: true) //7
outline.addLine(to: CGPoint(x: t31 + r, y: rect.size.height))
outline.addArc(withCenter: CGPoint(x: t31 + r, y: rect.size.height - r), radius: r, startAngle: c6, endAngle: c9, clockwise: true) //8
outline.addLine(to: CGPoint(x: t31, y: t32))
case .west:
outline.addLine(to: CGPoint(x: t31, y: t32))
outline.addArc(withCenter: CGPoint(x: rect.origin.x + r, y: t32 - r), radius: r, startAngle: c6, endAngle: c9, clockwise: true) //10
outline.addLine(to: CGPoint(x: rect.origin.x, y: t31 + r))
outline.addArc(withCenter: CGPoint(x: rect.origin.x + r, y: t31 + r), radius: r, startAngle: c9, endAngle: c12, clockwise: true) //11
outline.addLine(to: CGPoint(x: t31, y: t31))
case .east:
outline.addLine(to: CGPoint(x: t32, y: t31))
outline.addArc(withCenter: CGPoint(x: rect.size.width - r, y: t31 + r), radius: r, startAngle: c12, endAngle: c3, clockwise: true) //4
outline.addLine(to: CGPoint(x: rect.size.width, y: t32 - r))
outline.addArc(withCenter: CGPoint(x: rect.size.width - r, y: t32 - r), radius: r, startAngle: c3, endAngle: c6, clockwise: true) //5
outline.addLine(to: CGPoint(x: t32, y: t32))
}
outline.close()
} else {
let x0 = bounds.minX + 8
let x1 = bounds.maxX - 8
let y0 = bounds.minY + 8
let y1 = bounds.maxY - 8
switch direction {
case .none: break
case .north:
outline.addLine(to: CGPoint(x: x0, y: y0))
outline.addLine(to: CGPoint(x: x1, y: y0))
case .south:
outline.addLine(to: CGPoint(x: x1, y: y1))
outline.addLine(to: CGPoint(x: x0, y: y1))
case .west:
outline.addLine(to: CGPoint(x: x0, y: y1))
outline.addLine(to: CGPoint(x: x0, y: y0))
case .east:
outline.addLine(to: CGPoint(x: x1, y: y0))
outline.addLine(to: CGPoint(x: x1, y: y1))
}
outline.close()
}
let maskLayer = CAShapeLayer().apply {
$0.path = outline.cgPath
}
$0.layer.mask = maskLayer
$0.center = boundsCenter
self.addSubview($0)
}
}
func timeout(_ timer: Timer?) {
if !self.isRepeatable || !self.isRepeating || self.lastEvent == .none {
timer?.invalidate()
if !isSelected {
directionView?.removeFromSuperview()
directionView = nil
}
return
}
self.lastEvent = .touchDownRepeat
self.repeatCount += 1
self.delegate?.fourDirectionsTouchDownRepeat?(self)
self.drawDirection()
UIView.animate(withDuration: 0.2, animations: {
self.directionView?.alpha = 0
})
repeatingTimer = Timer.scheduledTimer(withTimeInterval: repeatInterval, repeats: false, block: { self.timeout($0) })
}
@discardableResult
func setValue(_ location: CGPoint, event: NKEvent) -> Bool {
let previousDirection = direction
lastEvent = event
let degree = degreesCW(location)
switch degree {
case 0.000 ..< 0.125: direction = .north
case 0.125 ..< 0.375: direction = .east
case 0.375 ..< 0.625: direction = .south
case 0.625 ..< 0.875: direction = .west
case 0.875 ..< 1.000: direction = .north
default: direction = .none
}
if direction == .north && !needDirection.contains(.north) { direction = .none }
if direction == .south && !needDirection.contains(.south) { direction = .none }
if direction == .east && !needDirection.contains(.east) { direction = .none }
if direction == .west && !needDirection.contains(.west) { direction = .none }
if direction == previousDirection { return false }
drawDirection()
if direction != .none && event == .touchDown && isRepeatable {
repeatCount = 0
if !isRepeating {
isRepeating = true
repeatingTimer = Timer.scheduledTimer(withTimeInterval: repeatDelay, repeats: false, block: { self.timeout($0) })
}
}
return true
}
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 }
return floor(1000 * r / (2 * .pi)) / 1000
}
}
FourDirectionsラッパー
FourDirectionsラッパーを表示
//
// FourDirections.swift
//
// Wrapper for NKFourDirections
//
import SwiftUI
struct FourDirections: UIViewRepresentable {
typealias Direction = NKFourDirections.NKDirection
typealias Event = NKFourDirections.NKEvent
typealias DirectionOptions = NKFourDirections.NKDirectionOptions
private var caption: String? = nil
private var isHold: Bool? = nil
private var crossFace: Bool? = nil
private var isRepeatable: Bool? = nil
private var repeatDelay: TimeInterval? = nil
private var repeatInterval: TimeInterval? = nil
private var needDirection: DirectionOptions? = nil
typealias Callback = (Direction, _ repeatCount: Int, Event) -> Void
private var onChange: Callback?
func makeUIView(context: Context) -> NKFourDirections {
let view = NKFourDirections(frame: CGRect.zero)
view.delegate = context.coordinator
return view
}
func updateUIView(_ fourDir: NKFourDirections, context: Context) {
if let caption = caption { fourDir.caption = caption }
if let isHold = isHold { fourDir.isHold = isHold }
if let crossFace = crossFace { fourDir.needCrossFace = crossFace }
if let isRepeatable = isRepeatable { fourDir.isRepeatable = isRepeatable }
if let repeatDelay = repeatDelay { fourDir.repeatDelay = repeatDelay }
if let repeatInterval = repeatInterval { fourDir.repeatInterval = repeatInterval }
if let needDirection = needDirection { fourDir.needDirection = needDirection }
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, NKFourDirectionsDelegate {
let parent: FourDirections
var onChangeCallback: Callback?
init(_ nkFourDirections: FourDirections) {
parent = nkFourDirections
}
func fourDirectionsTouchDown(_ fourDir: NKFourDirections) {
onChangeCallback?(fourDir.direction, fourDir.repeatCount, fourDir.lastEvent)
}
func fourDirectionsTouchUp(_ fourDir: NKFourDirections) {
onChangeCallback?(fourDir.direction, fourDir.repeatCount, fourDir.lastEvent)
}
func fourDirectionsTouchDownRepeat(_ fourDir: NKFourDirections) {
onChangeCallback?(fourDir.direction, fourDir.repeatCount, fourDir.lastEvent)
}
}
}
extension FourDirections {
func caption(_ caption: String) -> Self {
var view = self
view.caption = caption
return view
}
func hold(_ hold: Bool) -> Self {
var view = self
view.isHold = hold
return view
}
func crossFace(_ crossFace: Bool) -> Self {
var view = self
view.crossFace = crossFace
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
}
func needDirection(_ needDirection: DirectionOptions) -> Self {
var view = self
view.needDirection = needDirection
return view
}
}
class FourDirectionsViewModel: ObservableObject {
private weak var fourDirections: NKFourDirections?
func setFourDirections(_ fourDirections: NKFourDirections) {
self.fourDirections = fourDirections
}
func reset() {
fourDirections?.reset()
}
}
使用例
SwiftUIでの使用例を以下に示す。Previewでもちゃんと動作します。
import SwiftUI
struct ContentView: View {
@State var event: FourDirections.Event = .none
@State var dir: FourDirections.Direction = .none
@State var repeatCount: Int = 0
var body: some View {
VStack {
FourDirections()
.caption("four dir")
.repeatable(true)
.onChange { dir, repeatCount, event in
self.dir = dir
self.repeatCount = repeatCount
self.event = event
}
.frame(width:200, height: 200)
let text = "event: \(event), dir: \(dir), repeatCount: \(repeatCount)"
Text(text)
.lineLimit(1)
.minimumScaleFactor(0.5)
.frame(height: 40)
FourDirections()
.crossFace(false)
.hold(true)
.onChange { dir, repeatCount, event in
self.dir = dir
self.repeatCount = repeatCount
self.event = event
}
.frame(width:200, height: 200)
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
以下のオプションを用意しており、view modifierで指定する。
FourDirections()
//四角いフェースとする場合はfalse。デフォルトはtrueで十字フェース
.crossFace(false)
//ボタンの中央に表示する見出し
.caption("four dir")
//押し続けた時に自動リピートする場合にtrue。
//リピート開始までの遅延時間とリピート間隔を指定できる
.repeatable(true, interval: 0.2, delay: 1.0)
//タップして指を離しても『押したまま』とする場合にtrue。再度タップすると戻る
.hold(true)
//必要とする方向を限定する場合に指定する。省略すると全方向
.needDirection([.north, .south]) //上下のみ
//イベントハンドラ
//dir: タップした方向、repeatCount: リピート回数、event: イベント
//dir: north, south, west, east
//event: touchDown, touchUp, touchDownRepeat
.onChange { dir, repeatCount, event in <code> }
//サイズを必ず指定して正方形とすること
.frame(width:200, height: 200)
デモ動画
前項の使用例のコードを実行させた動画
よかったら使ってみてください。
以上
改訂履歴
-
改訂1 2023.6.22
・FourDirectionsラッパーのイベントハンドラを集約
・デモ動画をシミュレータ画面に変更 -
改訂2 2023.6.23
・共通コード最新化 -
改訂3 2023.6.25
・必要とする十字方向を指定できるようにした。指定されていない方向のイベントは通知しない