├── .github
├── FUNDING.yml
└── workflows
│ ├── swiftlint.yml
│ ├── build-multiplatform.yml
│ ├── build-documentation.yml
│ └── tests.yml
├── .spi.yml
├── .swiftlint.yml
├── Tests
└── GameControllerKitTests
│ └── GameControllerKitTests.swift
├── Package.swift
├── Sources
└── GameControllerKit
│ ├── GCKController.swift
│ ├── GCKControllerType.swift
│ ├── GCKMovePosition.swift
│ ├── GCKAction.swift
│ ├── GameControllerKit.swift
│ └── GCKControllerView.swift
├── LICENCE.md
├── .gitignore
├── README.md
└── .spm.swift
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: 0xWDG
2 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [GameControllerKit]
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | excluded:
2 | - "*resource_bundle_accessor*" # SwiftPM Generated
3 | - ".build/*"
4 |
5 | opt_in_rules:
6 | - missing_docs
7 | - empty_count
8 | - empty_string
9 | - toggle_bool
10 | - unused_optional_binding
11 | - valid_ibinspectable
12 | - modifier_order
13 | - first_where
14 | - fatal_error_message
15 | - force_unwrapping
16 |
17 |
--------------------------------------------------------------------------------
/.github/workflows/swiftlint.yml:
--------------------------------------------------------------------------------
1 | name: Run Swiftlint
2 | on:
3 | push:
4 | pull_request:
5 | workflow_dispatch:
6 |
7 | jobs:
8 | swiftlint:
9 | runs-on: macos-latest
10 | steps:
11 | - uses: actions/checkout@v3
12 |
13 | - name: SwiftLint
14 | run: |
15 | brew install swiftlint
16 | swiftlint --reporter github-actions-logging --strict
--------------------------------------------------------------------------------
/.github/workflows/build-multiplatform.yml:
--------------------------------------------------------------------------------
1 | # .github/workflows/build-multiplatform.yml
2 |
3 | name: Build-Packages
4 |
5 | on:
6 | # Run on pull_request
7 | pull_request:
8 |
9 | # Dispatch if triggered using Github (website)
10 | workflow_dispatch:
11 |
12 | jobs:
13 | Build-Packages:
14 | runs-on: macos-latest
15 | steps:
16 | - name: Build Swift Packages
17 | uses: 0xWDG/build-swift@main
--------------------------------------------------------------------------------
/.github/workflows/build-documentation.yml:
--------------------------------------------------------------------------------
1 | # .github/workflows/build-documentation.yml
2 |
3 | name: build-documentation
4 |
5 | on:
6 | # Run on push to main branch
7 | push:
8 | branches:
9 | - main
10 |
11 | # Dispatch if triggered using Github (website)
12 | workflow_dispatch:
13 |
14 | jobs:
15 | Build-documentation:
16 | runs-on: macos-latest
17 | steps:
18 | - name: Build documentation
19 | uses: 0xWDG/build-documentation@main
20 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Run tests on Linux and macOS
2 | on:
3 | push:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | test_linux:
8 | if: false
9 | runs-on: ubuntu-latest
10 | continue-on-error: true
11 | steps:
12 | - uses: actions/checkout@v3
13 |
14 | - name: Swift test
15 | run: swift test
16 |
17 | test_macos:
18 | runs-on: macos-latest
19 | steps:
20 | - uses: actions/checkout@v3
21 |
22 | - name: Swift test
23 | run: swift test
24 |
--------------------------------------------------------------------------------
/Tests/GameControllerKitTests/GameControllerKitTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GameControllerKitTests.swift
3 | // GameControllerKit
4 | //
5 | // Created by Wesley de Groot on 2024-08-19.
6 | // https://wesleydegroot.nl
7 | //
8 | // https://github.com/0xWDG/GameControllerKit
9 | // MIT License
10 | //
11 | import XCTest
12 | @testable import GameControllerKit
13 |
14 | final class GameControllerKitTests: XCTestCase {
15 | func testExample() throws {
16 | // XCTest Documentation
17 | // https://developer.apple.com/documentation/xctest
18 |
19 | // Defining Test Cases and Test Methods
20 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.8.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "GameControllerKit",
8 | platforms: [
9 | .macOS(.v12),
10 | .iOS(.v16),
11 | .tvOS(.v16)
12 | ],
13 | products: [
14 | // Products define the executables and libraries a package produces, making them visible to other packages.
15 | .library(
16 | name: "GameControllerKit",
17 | targets: ["GameControllerKit"])
18 | ],
19 | targets: [
20 | // Targets are the basic building blocks of a package, defining a module or a test suite.
21 | // Targets can depend on other targets in this package and products from dependencies.
22 | .target(
23 | name: "GameControllerKit"),
24 | .testTarget(
25 | name: "GameControllerKitTests",
26 | dependencies: ["GameControllerKit"])
27 | ]
28 | )
29 |
--------------------------------------------------------------------------------
/Sources/GameControllerKit/GCKController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GCKController.swift
3 | // GameControllerKit
4 | //
5 | // Created by Wesley de Groot on 2024-08-19.
6 | // https://wesleydegroot.nl
7 | //
8 | // https://github.com/0xWDG/GameControllerKit
9 | // MIT License
10 | //
11 |
12 | import Foundation
13 | import GameController
14 |
15 | /// Represents the names of buttons available on a standard game controller.
16 | ///
17 | /// This is a typealias to ``GCController``.
18 | public typealias GCKController = GCController
19 |
20 | extension GCKController {
21 | /// Does the current controller has a touchpad?
22 | public var hasTouchPad: Bool {
23 | if self.physicalInputProfile is GCDualSenseGamepad {
24 | return true
25 | }
26 |
27 | if self.physicalInputProfile is GCDualShockGamepad {
28 | return true
29 | }
30 |
31 | return false
32 | }
33 |
34 | /// Does the current controller have paddle buttons?
35 | public var hasPaddleButtons: Bool {
36 | return self.physicalInputProfile is GCXboxGamepad
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/LICENCE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Wesley de Groot, email@WesleydeGroot.nl
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Sources/GameControllerKit/GCKControllerType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GCKControllerType.swift
3 | // GameControllerKit
4 | //
5 | // Created by Wesley de Groot on 2024-08-19.
6 | // https://wesleydegroot.nl
7 | //
8 | // https://github.com/0xWDG/GameControllerKit
9 | // MIT License
10 | //
11 |
12 | import Foundation
13 |
14 | /// Represents the type of game controller connected.
15 | public enum GCKControllerType {
16 | /// A DualSense controller (PlayStation 5).
17 | case dualSense
18 |
19 | /// A DualShock controller (PlayStation 4).
20 | case dualShock
21 |
22 | /// An Xbox controller.
23 | case xbox
24 |
25 | /// An Siri Remote controller
26 | case siriRemote
27 |
28 | /// A generic controller type, for other controllers.
29 | case generic
30 |
31 | /// Description of the game controller
32 | public var description: String {
33 | switch self {
34 | case .dualSense:
35 | "DualSense"
36 | case .dualShock:
37 | "DualShock"
38 | case .xbox:
39 | "Xbox"
40 | case .siriRemote:
41 | "Siri Remote"
42 | case .generic:
43 | "Genric"
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## User settings
2 | xcuserdata/
3 |
4 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
5 | *.xcscmblueprint
6 | *.xccheckout
7 |
8 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
9 | build/
10 | DerivedData/
11 | *.moved-aside
12 | *.pbxuser
13 | !default.pbxuser
14 | *.mode1v3
15 | !default.mode1v3
16 | *.mode2v3
17 | !default.mode2v3
18 | *.perspectivev3
19 | !default.perspectivev3
20 |
21 | ## Obj-C/Swift specific
22 | *.hmap
23 |
24 | ## App packaging
25 | *.ipa
26 | *.dSYM.zip
27 | *.dSYM
28 |
29 | ## Playgrounds
30 | timeline.xctimeline
31 | playground.xcworkspace
32 |
33 | ### Swift Package Manager
34 | Packages/
35 | Package.pins
36 | Package.resolved
37 | # *.xcodeproj
38 | #
39 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
40 | # hence it is not needed unless you have added a package configuration file to your project
41 | .swiftpm
42 | .build/
43 |
44 | ### CocoaPods
45 | Pods/
46 | *.xcworkspace
47 |
48 | ### Carthage
49 | Carthage/Checkouts
50 | Carthage/Build/
51 |
52 | ### Accio dependency management
53 | Dependencies/
54 | .accio/
55 |
56 | ### fastlane
57 | fastlane/report.xml
58 | fastlane/Preview.html
59 | fastlane/screenshots/**/*.png
60 | fastlane/test_output
61 |
62 | ### Code Injection
63 | iOSInjectionProject/
64 |
--------------------------------------------------------------------------------
/Sources/GameControllerKit/GCKMovePosition.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GCKMovePosition.swift
3 | // GameControllerKit
4 | //
5 | // Created by Wesley de Groot on 2024-08-19.
6 | // https://wesleydegroot.nl
7 | //
8 | // https://github.com/0xWDG/GameControllerKit
9 | // MIT License
10 | //
11 |
12 | import Foundation
13 |
14 | /// Moding position of the thumbstick.
15 | public enum GCKMovePosition: Comparable {
16 | /// The thumbstick position is up
17 | case up
18 | // swiftlint:disable:previous identifier_name
19 |
20 | /// The thumbstick position is up - right
21 | case upRight
22 |
23 | /// The thumbstick position is right
24 | case right
25 |
26 | /// The thumbstick position is down - right
27 | case downRight
28 |
29 | /// The thumbstick position is down
30 | case down
31 |
32 | /// The thumbstick position is down - left
33 | case downLeft
34 |
35 | /// The thumbstick position is left
36 | case left
37 |
38 | /// The thumbstick position is up - left
39 | case upLeft
40 |
41 | /// The thumbstick position is centered
42 | case centered
43 |
44 | /// The thumbstick position is unknown (usually a phase in between a position and center)
45 | /// if it keeps being unknown, check if the event you are sending is correct.
46 | case unknown
47 |
48 | public var arrowRepresentation: String {
49 | switch self {
50 | case .up:
51 | "↑"
52 | case .upRight:
53 | "↗"
54 | case .right:
55 | "→"
56 | case .downRight:
57 | "↘"
58 | case .down:
59 | "↓"
60 | case .downLeft:
61 | "↙"
62 | case .left:
63 | "←"
64 | case .upLeft:
65 | "↖"
66 | case .centered:
67 | "•"
68 | case .unknown:
69 | ""
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GameControllerKit
2 |
3 | GameControllerKit is a Swift package that makes it easy to work with game controllers on iOS, macOS, and tvOS. It provides a simple API to connect to game controllers, read input from them, and control their lights and haptics.
4 |
5 | [](https://swiftpackageindex.com/0xWDG/GameControllerKit)
6 | [](https://swiftpackageindex.com/0xWDG/GameControllerKit)
7 | [](https://swift.org/package-manager)
8 | 
9 |
10 | ## Requirements
11 |
12 | - Swift 5.9+ (Xcode 15+)
13 | - iOS 13+, macOS 10.15+, tvOS 16+
14 |
15 | ## Installation (Pakage.swift)
16 |
17 | ```swift
18 | dependencies: [
19 | .package(url: "https://github.com/0xWDG/GameControllerKit.git", branch: "main"),
20 | ],
21 | targets: [
22 | .target(name: "MyTarget", dependencies: [
23 | .product(name: "GameControllerKit", package: "GameControllerKit"),
24 | ]),
25 | ]
26 | ```
27 |
28 | ## Installation (Xcode)
29 |
30 | 1. In Xcode, open your project and navigate to **File** → **Swift Packages** → **Add Package Dependency...**
31 | 2. Paste the repository URL (`https://github.com/0xWDG/GameControllerKit`) and click **Next**.
32 | 3. Click **Finish**.
33 |
34 | ## Usage
35 |
36 | ```swift
37 | import SwiftUI
38 | import GameControllerKit
39 |
40 | struct ContentView: View {
41 | /// The game controller kit
42 | @State
43 | var gameController = GameControllerKit()
44 |
45 | /// Log
46 | @State
47 | var log: [String] = []
48 |
49 | var body: some View {
50 | VStack {
51 | Button {
52 | gameController.set(color: .GCKRandom)
53 | } label: {
54 | Text("Random Color")
55 | }
56 |
57 | Text("Controller: \(gameController.controller?.productCategory ?? "None"), " +
58 | "\((gameController.controllerType ?? .generic).description)")
59 | Text("Last action:\n\(String(describing: gameController.lastAction)).")
60 |
61 | GCKControllerView()
62 | .environmentObject(gameController)
63 |
64 | List {
65 | ForEach(log.reversed(), id: \.self) { text in
66 | Text(text)
67 | }
68 | }
69 | }
70 | .padding()
71 | .onAppear {
72 | gameController.set(handler: handler)
73 | UIApplication.shared.isIdleTimerDisabled = true
74 | }
75 | }
76 |
77 | /// Handler
78 | ///
79 | /// - Parameters:
80 | /// - action: action
81 | /// - pressed: is the button pressed?
82 | /// - controller: which controller?
83 | public func handler(
84 | action: GCKAction,
85 | pressed: Bool,
86 | controller: GCKController
87 | ) {
88 | log.append(
89 | "\(String(describing: action))(\(action.position.arrowRepresentation)) \(pressed ? "Pressed" : "Unpressed"), " +
90 | "Controller #id \(String(describing: controller.playerIndex.rawValue))"
91 | )
92 |
93 | if action == .buttonA && pressed {
94 | // set to a random color
95 | gameController.set(color: .GCKRandom)
96 | }
97 | }
98 | }
99 | ```
100 |
101 | ## Image of Usage Demo App
102 |
103 | ### iOS
104 | 
105 |
106 | ### MacOS
107 |
108 |
109 | ### tvOS
110 | 
111 |
112 | ## Mentions
113 |
114 | - Touch-free Touch Screens by Rob Whitaker
115 | https://appdevcon.nl/session/touch-free-touch-screens/ @ AppDevCon 2025
116 | https://www.youtube.com/watch?v=bHPMuVyjBYw @ iOSKonf25
117 |
118 | ## Contact
119 |
120 | 🦋 [@0xWDG](https://bsky.app/profile/0xWDG.bsky.social)
121 | 🐘 [mastodon.social/@0xWDG](https://mastodon.social/@0xWDG)
122 | 🐦 [@0xWDG](https://x.com/0xWDG)
123 | 🧵 [@0xWDG](https://www.threads.net/@0xWDG)
124 | 🌐 [wesleydegroot.nl](https://wesleydegroot.nl)
125 | 🤖 [Discord](https://discordapp.com/users/918438083861573692)
126 |
127 | Interested learning more about Swift? [Check out my blog](https://wesleydegroot.nl/blog/).
128 |
129 |
--------------------------------------------------------------------------------
/Sources/GameControllerKit/GCKAction.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GCKAction.swift
3 | // GameControllerKit
4 | //
5 | // Created by Wesley de Groot on 2024-08-19.
6 | // https://wesleydegroot.nl
7 | //
8 | // https://github.com/0xWDG/GameControllerKit
9 | // MIT License
10 | //
11 |
12 | import Foundation
13 |
14 | /// Represents the names of buttons available on a standard game controller.
15 | public enum GCKAction: Comparable {
16 | /// The A button, often used for main actions.
17 | /// ("Cross" on PlayStation Controllers)
18 | case buttonA
19 |
20 | /// The B button, often used for secondary actions or canceling.
21 | /// ("Circle" on PlayStation Controllers)
22 | case buttonB
23 |
24 | /// The X button, typically used for alternative actions.
25 | /// ("Square" on PlayStation Controllers)
26 | case buttonX
27 |
28 | /// The Y button, also used for alternative actions or menu navigation.
29 | /// ("Triangle" on PlayStation Controllers)
30 | case buttonY
31 |
32 | /// The left shoulder button, usually triggers shoulder actions or item switching.
33 | /// ("L1" on PlayStation Controllers)
34 | case leftShoulder
35 |
36 | /// The right shoulder button, similar to the left shoulder in use. ("R1" on PlayStation Controllers)
37 | case rightShoulder
38 |
39 | /// The left trigger, often used for actions like aiming or braking. ("L2" on PlayStation Controllers)
40 | case leftTrigger
41 |
42 | /// The right trigger, commonly used for actions such as shooting or accelerating. ("R2" on PlayStation Controllers)
43 | case rightTrigger
44 |
45 | /// The menu button, typically brings up in-game or app menus. ("Options" on PlayStation Controllers)
46 | case buttonMenu
47 |
48 | /// The options button, can be used for additional in-game options. ("Share" on PlayStation Controllers)
49 | case buttonOptions
50 |
51 | /// The home button, often used to exit to the main menu or dashboard. ("PlayStation" on PlayStation Controllers)
52 | case buttonHome
53 |
54 | /// The directional pad up button.
55 | case dpadUp
56 |
57 | /// The directional pad down button.
58 | case dpadDown
59 |
60 | /// The directional pad left button.
61 | case dpadLeft
62 |
63 | /// The directional pad right button.
64 | case dpadRight
65 |
66 | /// The left thumbstick click button, used for additional actions.
67 | case leftThumbstickButton
68 |
69 | /// The right thumbstick click button, similar to the left thumbstick button.
70 | case rightThumbstickButton
71 |
72 | /// The right thumbstick
73 | ///
74 | /// - Parameter x: X-axis
75 | /// - Parameter y: Y-axis
76 | case leftThumbstick(x: Float, y: Float)
77 | // swiftlint:disable:previous identifier_name
78 |
79 | /// The right thumbstick
80 | ///
81 | /// - Parameter x: X-axis
82 | /// - Parameter y: Y-axis
83 | case rightThumbstick(x: Float, y: Float)
84 | // swiftlint:disable:previous identifier_name
85 |
86 | /// PlayStation: The touchpad button, which can also act as a clickable button.
87 | case touchpadButton
88 |
89 | /// PlayStation: The top part of the touchpad,
90 | /// acting as the up directional input that is touched or pressed by the primary finger.
91 | case touchpadPrimaryUp
92 |
93 | /// PlayStation: The bottom part of the touchpad,
94 | /// acting as the up directional input that is touched or pressed by the primary finger.
95 | case touchpadPrimaryDown
96 |
97 | /// PlayStation: The left part of the touchpad,
98 | /// acting as the up directional input that is touched or pressed by the primary finger.
99 | case touchpadPrimaryLeft
100 |
101 | /// PlayStation: The right part of the touchpad,
102 | /// acting as the up directional input that is touched or pressed by the primary finger.
103 | case touchpadPrimaryRight
104 |
105 | /// PlayStation: The top part of the touchpad,
106 | /// acting as the up directional input that is touched or pressed by the secondary finger.
107 | case touchpadSecondaryUp
108 |
109 | /// PlayStation: The bottom part of the touchpad,
110 | /// acting as the up directional input that is touched or pressed by the secondary finger.
111 | case touchpadSecondaryDown
112 |
113 | /// PlayStation: The left part of the touchpad,
114 | /// acting as the up directional input that is touched or pressed by the secondary finger.
115 | case touchpadSecondaryLeft
116 |
117 | /// PlayStation: The right part of the touchpad,
118 | /// acting as the up directional input that is touched or pressed by the secondary finger.
119 | case touchpadSecondaryRight
120 |
121 | /// Xbox: The paddle 1 button element, which has a P1 label on the back of the controller.
122 | case paddleButton1
123 |
124 | /// Xbox: The paddle 2 button element, which has a P2 label on the back of the controller.
125 | case paddleButton2
126 |
127 | /// Xbox: The paddle 3 button element, which has a P2 label on the back of the controller.
128 | case paddleButton3
129 |
130 | /// Xbox: The paddle 4 button element, which has a P2 label on the back of the controller.
131 | case paddleButton4
132 |
133 | /// Xbox: The share button on an Xbox Series X|S controller or later.
134 | case shareButton
135 |
136 | /// None: initial value
137 | case none
138 |
139 | /// Get the position of the thumbstick.
140 | public var position: GCKMovePosition {
141 | var position: GCKMovePosition = .unknown
142 |
143 | switch self {
144 | case .leftThumbstick(let xPos, let yPos), .rightThumbstick(let xPos, let yPos):
145 | if yPos == 1.0 {
146 | position = .up
147 | } else if xPos > 0 && xPos < 1 && yPos > 0 && yPos < 1 {
148 | position = .upRight
149 | } else if xPos == 1.0 {
150 | position = .right
151 | } else if xPos > 0 && xPos < 1 && yPos < 0 && yPos > -1 {
152 | position = .downRight
153 | } else if yPos == -1.0 {
154 | position = .down
155 | } else if xPos < 0 && xPos > -1 && yPos < 0 && yPos > -1 {
156 | position = .downLeft
157 | } else if xPos == -1.0 {
158 | position = .left
159 | } else if xPos < 0 && xPos > -1 && yPos > 0 && yPos < 1 {
160 | position = .upLeft
161 | } else if xPos == 0 && yPos == 0 {
162 | position = .centered
163 | } else {
164 | position = .unknown
165 | }
166 |
167 | default:
168 | position = GCKMovePosition.unknown
169 | }
170 |
171 | return position
172 | }
173 |
174 | /// Is the current action a thumbstick action
175 | public var thumbStickAction: Bool {
176 | return switch self {
177 | case
178 | .leftThumbstick,
179 | .rightThumbstick:
180 | true
181 |
182 | default:
183 | false
184 | }
185 | }
186 |
187 | /// Is the current action a touchpad action (Playstation Only)
188 | public var touchPadAction: Bool {
189 | return switch self {
190 | case
191 | .touchpadButton,
192 | .touchpadPrimaryUp,
193 | .touchpadPrimaryRight,
194 | .touchpadPrimaryDown,
195 | .touchpadPrimaryLeft,
196 | .touchpadSecondaryUp,
197 | .touchpadSecondaryRight,
198 | .touchpadSecondaryDown,
199 | .touchpadSecondaryLeft:
200 | true
201 |
202 | default:
203 | false
204 | }
205 | }
206 |
207 | /// Is the current action a paddle action (Xbox Only)
208 | public var paddleAction: Bool {
209 | return switch self {
210 | case
211 | .paddleButton1,
212 | .paddleButton2,
213 | .paddleButton3,
214 | .paddleButton4:
215 | true
216 |
217 | default:
218 | false
219 | }
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/.spm.swift:
--------------------------------------------------------------------------------
1 | #!/usr/bin/swift
2 |
3 | //
4 | // spm.swift
5 | // This script will add a header to all .swift files in the current directory.
6 | // And can test the package for various platforms.
7 | //
8 | // Created by Wesley de Groot on 2024-08-06.
9 | // https://wesleydegroot.nl
10 | //
11 | // https://github.com/0xWDG/spm-template
12 | // MIT License
13 |
14 | // To compile this script to a binary, run:
15 | // swiftc .spm.swift -o spm
16 |
17 | // swiftlint:disable all
18 | import Foundation
19 |
20 | let fileManager = FileManager.default
21 | var internalProductName: String?
22 | var productName: String {
23 | get {
24 | if let productName = internalProductName {
25 | return productName
26 | }
27 |
28 | if fileManager.fileExists(atPath: "Package.swift") {
29 | guard let package = try? String(contentsOf: URL(fileURLWithPath: "Package.swift"), encoding: .utf8),
30 | let productName = package
31 | .components(separatedBy: .newlines)
32 | .first(where: { $0.contains("name:") })?
33 | .components(separatedBy: .whitespaces)
34 | .last?
35 | .replacingOccurrences(of: "\"", with: "")
36 | .replacingOccurrences(of: ",", with: "") else {
37 | printC("Could not find product name in Package.swift", color: CLIColors.red)
38 | exit(2)
39 | }
40 |
41 | return productName
42 | } else {
43 | printC("Package.swift not found, please provide package name", color: CLIColors.red)
44 | return ""
45 | }
46 | }
47 | set {
48 | internalProductName = newValue
49 | }
50 | }
51 |
52 | struct CLIColors {
53 | static let red = "\u{001B}[0;31m"
54 | static let green = "\u{001B}[0;32m"
55 | static let yellow = "\u{001B}[0;33m"
56 | static let orange = "\u{001B}[0;38;5;208m"
57 | static let blue = "\u{001B}[0;34m"
58 | static let magenta = "\u{001B}[0;35m"
59 | static let cyan = "\u{001B}[0;36m"
60 | static let white = "\u{001B}[0;37m"
61 | static let reset = "\u{001B}[0;0m"
62 | static let clear = "\u{001B}[0;0m"
63 | }
64 |
65 | func printUsage() {
66 | print("Usage: \(CommandLine.arguments[0]) ")
67 | print("Commands:")
68 | print(" create - Add a header to all .swift files in the current directory")
69 | print(" header - Update the header for all .swift files in the current directory")
70 | print(" readme - Generate a README.md file for the package")
71 | print(" build - Build the package for all platforms")
72 | print(" test - Test the package for all platforms")
73 | }
74 |
75 | func printC(_ text: String, terminator: String = "\n", color: String = CLIColors.reset) {
76 | if terminator == "\n" {
77 | print("\(color)\(text) \(CLIColors.reset)")
78 | } else {
79 | print("\(color)\(text)\(CLIColors.reset)", terminator: terminator)
80 | fflush(stdout)
81 | }
82 | }
83 |
84 | if CommandLine.argc < 2 {
85 | printUsage()
86 | exit(1)
87 | }
88 |
89 | if CommandLine.arguments[1] == "create" && CommandLine.argc < 3 {
90 | print("Usage: \(CommandLine.arguments[0]) create ")
91 | exit(1)
92 | }
93 |
94 | if CommandLine.arguments[1] == "create" && CommandLine.argc == 3 {
95 | productName = CommandLine.arguments[2]
96 |
97 | if !fileManager.fileExists(atPath: "Package.swift") {
98 | let process = Process()
99 | process.launchPath = "/usr/bin/env"
100 | process.arguments = ["swift", "package", "init", "--name", productName]
101 | process.launch()
102 | process.waitUntilExit()
103 | }
104 |
105 | /// Change the first line of the Package.swift file
106 | let package = try String(contentsOf: URL(fileURLWithPath: "Package.swift"), encoding: .utf8)
107 | let newPackage = package
108 | .components(separatedBy: .newlines)
109 | .enumerated()
110 | .map { index, line in
111 | if index == 0 {
112 | return "// swift-tools-version: 5.8.0"
113 | } else {
114 | return line
115 | }
116 | }
117 | .joined(separator: "\n")
118 | try? newPackage.write(to: URL(fileURLWithPath: "Package.swift"), atomically: true, encoding: .utf8)
119 | printC("Downgraded swift-tools-version to 5.8.0", color: CLIColors.green)
120 |
121 | let spi = """
122 | version: 1
123 | builder:
124 | configs:
125 | - documentation_targets: [\(productName)]
126 | """
127 | try spi.write(to: URL(fileURLWithPath: ".spi.yml"), atomically: true, encoding: .utf8)
128 |
129 | header()
130 | generateReadme()
131 | }
132 |
133 | if CommandLine.arguments[1] == "header" {
134 | header()
135 | }
136 |
137 | if CommandLine.arguments[1] == "readme" {
138 | generateReadme()
139 | }
140 |
141 | if CommandLine.arguments[1] == "build" {
142 | if !fileManager.fileExists(atPath: "Package.swift") {
143 | printC("Package.swift not found", color: CLIColors.red)
144 | exit(2)
145 | }
146 |
147 | // Find platforms in Package.swift
148 | let package = try String(contentsOf: URL(fileURLWithPath: "Package.swift"), encoding: .utf8)
149 | var platforms: [String] = []
150 | var fails = 0
151 |
152 | if package.contains(".iOS") {
153 | platforms.append("iOS")
154 | }
155 |
156 | if package.contains(".macOS") {
157 | platforms.append("macOS")
158 | }
159 |
160 | if package.contains(".watchOS") {
161 | platforms.append("watchOS")
162 | }
163 |
164 | if package.contains(".visionOS") {
165 | platforms.append("xrOS")
166 | }
167 |
168 | if package.contains(".tvOS") {
169 | platforms.append("tvOS")
170 | }
171 |
172 | if package.contains(".maccatalyst") {
173 | platforms.append("MacCatalyst")
174 | }
175 |
176 | if package.contains(".driverkit") {
177 | printC("DriverKit is not supported, skipped", color: CLIColors.orange)
178 | // platforms.append("DriverKit")
179 | }
180 |
181 | if package.contains(".linux") {
182 | printC("Linux is not supported, skipped", color: CLIColors.orange)
183 | // platforms.append("Linux")
184 | }
185 |
186 | if package.contains(".android") {
187 | printC("Android is not supported, skipped", color: CLIColors.orange)
188 | // platforms.append("Android")
189 | }
190 |
191 | if platforms.isEmpty {
192 | printC("No platforms found in Package.swift, defaulting to all", color: CLIColors.orange)
193 | platforms = ["iOS", "tvOS", "xrOS", "watchOS", "macOS"]
194 | }
195 |
196 | printC("Build \(productName) for \(platforms.joined(separator: ", "))...")
197 |
198 | for (number, platform) in platforms.enumerated() {
199 | printC("Building \(productName) on \(platform). (\(number + 1)/\(platforms.count))", terminator: "\r")
200 | let process = Process()
201 | process.launchPath = "/usr/bin/env"
202 | process.arguments = [
203 | "xcrun",
204 | "xcodebuild",
205 | "clean",
206 | "build",
207 | "-quiet",
208 | "-scheme", productName,
209 | "-destination", "generic/platform=\(platform)"
210 | ]
211 | process.launch()
212 | process.waitUntilExit()
213 | // Check if the process was successful
214 | if process.terminationStatus != 0 {
215 | fails += 1
216 | printC("Failed to build for \(platform) (\(number + 1)/\(platforms.count))", color: CLIColors.red)
217 | } else {
218 | printC("Build for \(platform) successful (\(number + 1)/\(platforms.count)) ", color: CLIColors.green)
219 | }
220 | }
221 |
222 | if fails > 0 {
223 | printC("Failed to build for \(fails) platforms", color: CLIColors.red)
224 | } else {
225 | printC("Build for all platforms successful", color: CLIColors.green)
226 | }
227 |
228 | exit(0)
229 | }
230 |
231 | if CommandLine.arguments[1] == "test" {
232 | printC("Testing is not yet implemented", color: CLIColors.red)
233 | exit(99)
234 | }
235 |
236 | func header() {
237 | // Search for all .swift files
238 | let enumerator = fileManager.enumerator(atPath: ".")
239 | while let element = enumerator?.nextObject() as? String {
240 | if element.hasSuffix(".swift") {
241 | var headerLines = 0
242 |
243 | let dateFormatter = DateFormatter()
244 | dateFormatter.dateFormat = "yyyy-MM-dd"
245 | let date = dateFormatter.string(from: Date())
246 |
247 | var createdBy = "// Created by Wesley de Groot on \(date)."
248 | let file = element
249 | let path = URL(fileURLWithPath: file)
250 | guard let contents = try? String(contentsOf: path, encoding: .utf8) else {
251 | printC("Failed to read \(file)", color: CLIColors.red)
252 | continue
253 | }
254 | let filename = file.components(separatedBy: "/").last
255 | var lines = contents.components(separatedBy: .newlines)
256 |
257 | if lines.isEmpty {
258 | break
259 | }
260 |
261 | if lines[0].hasPrefix("#!") || file == "Package.swift" {
262 | continue
263 | }
264 |
265 | for line in lines {
266 | if line.hasPrefix("//") {
267 | if line.contains("Created by") {
268 | createdBy = line
269 | }
270 |
271 | headerLines += 1
272 | } else {
273 | break
274 | }
275 | }
276 |
277 | lines.removeFirst(Int(headerLines))
278 |
279 | let header = [
280 | "//",
281 | "// \(filename ?? "")",
282 | "// \(productName)",
283 | "//",
284 | createdBy,
285 | "// https://wesleydegroot.nl",
286 | "//",
287 | "// https://github.com/0xWDG/\(productName)",
288 | "// MIT License",
289 | "//"
290 | ]
291 |
292 | lines.insert(contentsOf: header, at: 0)
293 | let newContents = lines.joined(separator: "\n")
294 | do {
295 | try newContents.write(to: path, atomically: true, encoding: .utf8)
296 | printC("Updated header for \(file)", color: CLIColors.green)
297 | } catch {
298 | printC("Failed to update header for \(file)", color: CLIColors.red)
299 | }
300 | }
301 | }
302 | }
303 |
304 | func generateReadme() {
305 | var readme = """
306 | # PACKAGENAME
307 |
308 | PACKAGENAME is a Swift Package for ...
309 |
310 | [](https://swiftpackageindex.com/0xWDG/PACKAGENAME)
311 | [](https://swiftpackageindex.com/0xWDG/PACKAGENAME)
312 | [](https://swift.org/package-manager)
313 | 
314 |
315 | ## Requirements
316 |
317 | - Swift 5.9+ (Xcode 15+)
318 | - iOS 13+, macOS 10.15+
319 |
320 | ## Installation (Pakage.swift)
321 |
322 | ```swift
323 | dependencies: [
324 | .package(url: "https://github.com/0xWDG/PACKAGENAME.git", branch: "main"),
325 | ],
326 | targets: [
327 | .target(name: "MyTarget", dependencies: [
328 | .product(name: "PACKAGENAME", package: "PACKAGENAME"),
329 | ]),
330 | ]
331 | ```
332 |
333 | ## Installation (Xcode)
334 |
335 | 1. In Xcode, open your project and navigate to **File** → **Swift Packages** → **Add Package Dependency...**
336 | 2. Paste the repository URL (`https://github.com/0xWDG/PACKAGENAME`) and click **Next**.
337 | 3. Click **Finish**.
338 |
339 | ## Usage
340 |
341 | ```swift
342 | import SwiftUI
343 | import PACKAGENAME
344 |
345 | struct ContentView: View {
346 | var body: some View {
347 | VStack {
348 | /// ...
349 | }
350 | .padding()
351 | }
352 | }
353 | ```
354 |
355 | ## Contact
356 |
357 | We can get in touch via [Mastodon](https://mastodon.social/@0xWDG), [Twitter/X](https://twitter.com/0xWDG), [Discord](https://discordapp.com/users/918438083861573692), [Email](mailto:email@wesleydegroot.nl), [Website](https://wesleydegroot.nl).
358 |
359 | Interested learning more about Swift? [Check out my blog](https://wesleydegroot.nl/blog/).
360 | """
361 |
362 | readme = readme.replacingOccurrences(of: "PACKAGENAME", with: productName)
363 |
364 | try? readme.write(to: URL(fileURLWithPath: "README.md"), atomically: true, encoding: .utf8)
365 | }
366 |
367 | if CommandLine.arguments[1] != "executable" {
368 | let process = Process()
369 | process.launchPath = "/usr/bin/env"
370 | process.arguments = ["swiftc", CommandLine.arguments[0], "-o", "spm"]
371 | process.launch()
372 | process.waitUntilExit()
373 |
374 | if process.terminationStatus != 0 {
375 | printC("Failed to compile script", color: CLIColors.red)
376 | exit(4)
377 | } else {
378 | printC("Script compiled successfully", color: CLIColors.green)
379 | }
380 | }
381 | // swiftlint:enable all
382 |
--------------------------------------------------------------------------------
/Sources/GameControllerKit/GameControllerKit.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GameControllerKit.swift
3 | // GameControllerKit
4 | //
5 | // Created by Wesley de Groot on 2024-08-19.
6 | // https://wesleydegroot.nl
7 | //
8 | // https://github.com/0xWDG/GameControllerKit
9 | // MIT License
10 | //
11 |
12 | import Foundation
13 | import GameController
14 | import CoreHaptics
15 | import OSLog
16 |
17 | /// Game Controller Kit
18 | ///
19 | /// GameControllerKit is a Swift package that makes it easy to work with
20 | /// game controllers on iOS, macOS, and tvOS. It provides a simple API to
21 | /// connect to game controllers, read input from them, and control their
22 | /// lights and haptics.
23 | public class GameControllerKit: ObservableObject {
24 | /// Event Handler
25 | public typealias GCKEventHandler = (_ action: GCKAction, _ pressed: Bool, _ controller: GCKController) -> Void
26 |
27 | /// The type of game controller that is currently connected, if any.
28 | /// This property is nil if no controller is connected.
29 | @Published
30 | public var controllerType: GCKControllerType? = .none
31 |
32 | /// The game controller that is currently connected, if any. (this is always the first controller)
33 | @Published
34 | public var controller: GCKController?
35 |
36 | /// The game controllers that are currently connected, if any.
37 | @Published
38 | public var controllers: [GCKController] = []
39 |
40 | /// The current state of the left thumbstick.
41 | @Published
42 | public var leftThumbstick: GCKMovePosition = .centered
43 |
44 | /// The current state of the right thumbstick.
45 | @Published
46 | public var rightThumbstick: GCKMovePosition = .centered
47 |
48 | /// The last action done by the controller
49 | @Published
50 | public var lastAction: GCKAction = .none
51 |
52 | /// Action handler for the actions performed by the user on the controller
53 | private var eventHandler: GCKEventHandler?
54 |
55 | /// Game Controller Kit logger.
56 | private var logger = Logger(
57 | subsystem: "nl.wesleydegroot.GameControllerKit",
58 | category: "GameControllerKit"
59 | )
60 |
61 | /// Indicates whether a game controller is currently connected.
62 | public var isConnected: Bool = false
63 |
64 | /// Initializes a new GameControllerKit instance.
65 | /// It sets up notification observers for when game controllers connect or disconnect.
66 | ///
67 | /// - Parameter logger: Custom ``Logger`` instance. (optional)
68 | public init(logger: Logger? = nil) {
69 | NotificationCenter.default.addObserver(
70 | forName: .GCControllerDidConnect,
71 | object: nil,
72 | queue: .main,
73 | using: controllerDidConnect
74 | )
75 |
76 | NotificationCenter.default.addObserver(
77 | forName: .GCControllerDidDisconnect,
78 | object: nil,
79 | queue: .main,
80 | using: controllerDidDisconnect
81 | )
82 |
83 | self.eventHandler = { [weak self] button, pressed, controller in
84 | let message = "Controller #\(String(describing: controller.playerIndex.rawValue)), " +
85 | "Button \(String(describing: button)) \(button.position.arrowRepresentation) " +
86 | "is \(pressed ? "Pressed" : "Unpressed")"
87 |
88 | self?.logger.info("\(String(describing: message))")
89 | }
90 |
91 | if let logger = logger {
92 | self.logger = logger
93 | }
94 | }
95 |
96 | /// Set color of the controllers light
97 | ///
98 | /// Use the light settings to signal the user or to create a more immersive experience.
99 | /// If the controller doesn’t provide light settings, this property is nil.
100 | ///
101 | /// - Parameter color: Color
102 | public func set(color: GCColor) {
103 | controller?.light?.color = color
104 | }
105 |
106 | /// Set the event handler
107 | ///
108 | /// This function allows you to setup a custom event handler,
109 | /// which you need to receive inputs from the controller.
110 | ///
111 | /// - Parameter handler: event handler
112 | public func set(handler: @escaping GCKEventHandler) {
113 | self.eventHandler = handler
114 | }
115 |
116 | /// Plays random colors on your controller (if supported)
117 | /// This is currently only supported on a DualSense and DualShock controller (Playstation)
118 | public func randomColors() {
119 | for counter in 0...10 {
120 | DispatchQueue.main.asyncAfter(deadline: .now() + (Double(counter)/0.99)) {
121 | self.set(color: .GCKRandom)
122 | }
123 | }
124 | }
125 |
126 | /// Play haptics
127 | ///
128 | /// This plays haptics (vibrations) on the gamecontroller.
129 | ///
130 | /// - Parameter url: Haptics file
131 | public func playHaptics(url: URL) {
132 | guard let haptics = self.controller?.haptics?.createEngine(withLocality: .default) else {
133 | logger.fault("Couldn't initialize haptics")
134 | return
135 | }
136 |
137 | do {
138 | // Start the engine in case it's idle.
139 | try haptics.start()
140 |
141 | // Tell the engine to play a pattern.
142 | try haptics.playPattern(from: url)
143 | } catch {
144 | // Process engine startup errors.
145 | logger.fault("An error occured playing haptics: \(error).")
146 | }
147 | }
148 |
149 | // MARK: - Connect/Disconnect functions
150 | /// Controller did connect
151 | ///
152 | /// This function handles the connection of a controller.
153 | /// If it is the first controller it will set to the primary controller
154 | @objc private func controllerDidConnect(_ notification: Notification) {
155 | controllers = GCController.controllers()
156 |
157 | guard !controllers.isEmpty else {
158 | logger.fault("Failed to get the controller")
159 | return
160 | }
161 |
162 | for (index, currentController) in controllers.enumerated() {
163 | currentController.playerIndex = GCControllerPlayerIndex(rawValue: index) ?? .indexUnset
164 |
165 | let currentControllerType: GCKControllerType = switch currentController.physicalInputProfile {
166 | case is GCDualSenseGamepad:
167 | .dualSense
168 |
169 | case is GCDualShockGamepad:
170 | .dualShock
171 |
172 | case is GCXboxGamepad:
173 | .xbox
174 |
175 | case is GCMicroGamepad:
176 | .siriRemote
177 | default:
178 | .generic
179 | }
180 |
181 | let contr = String(describing: currentControllerType)
182 | logger.info(
183 | "Did connect controller \(currentController.productCategory) recognized as \(contr)."
184 | )
185 |
186 | if !isConnected && currentControllerType != .siriRemote {
187 | isConnected = true
188 | controller = currentController
189 | controllerType = currentControllerType
190 |
191 | logger.info(
192 | "Did set controller \(currentController.productCategory) as main (first) controller."
193 | )
194 | }
195 |
196 | setupController(controller: currentController)
197 | }
198 | }
199 |
200 | /// Controller did disconnect
201 | ///
202 | /// This function handles the disconnection of a controller.
203 | @objc private func controllerDidDisconnect(_ notification: Notification) {
204 | controllers = GCController.controllers()
205 |
206 | if controller == notification.object as? GCController? {
207 | logger.debug("The primary controller is disconnected")
208 | isConnected = false
209 | self.controllerType = nil
210 |
211 | if !controllers.isEmpty {
212 | logger.debug("Setup a new primary controller")
213 | controllerDidConnect(notification)
214 | }
215 |
216 | return
217 | }
218 |
219 | logger.debug("A controller is disconnected")
220 | }
221 |
222 | // MARK: - Setup controller
223 |
224 | /// Set up controller
225 | ///
226 | /// This function sets up the controller,
227 | /// it looks which type it is and then map the elements to the corresponding responders.
228 | ///
229 | /// - Parameter controller: Controller
230 | func setupController(controller: GCController) {
231 | // swiftlint:disable:previous function_body_length
232 | var buttons: [(GCControllerButtonInput?, GCKAction)] = []
233 |
234 | if let gamepad = controller.extendedGamepad {
235 | buttons.append(contentsOf: [
236 | (gamepad.buttonA, .buttonA),
237 | (gamepad.buttonB, .buttonB),
238 | (gamepad.buttonX, .buttonX),
239 | (gamepad.buttonY, .buttonY),
240 | (gamepad.leftShoulder, .leftShoulder),
241 | (gamepad.rightShoulder, .rightShoulder),
242 | (gamepad.leftTrigger, .leftTrigger),
243 | (gamepad.rightTrigger, .rightTrigger),
244 | (gamepad.buttonMenu, .buttonMenu),
245 | (gamepad.buttonOptions, .buttonOptions),
246 | (gamepad.buttonHome, .buttonHome),
247 | (gamepad.leftThumbstickButton, .leftThumbstickButton),
248 | (gamepad.rightThumbstickButton, .rightThumbstickButton),
249 | (gamepad.dpad.up, .dpadUp),
250 | (gamepad.dpad.down, .dpadDown),
251 | (gamepad.dpad.left, .dpadLeft),
252 | (gamepad.dpad.right, .dpadRight)
253 | ])
254 |
255 | if let playstationGamepad = controller.physicalInputProfile as? GCDualSenseGamepad {
256 | buttons.append(
257 | contentsOf: [
258 | (playstationGamepad.touchpadButton, .touchpadButton),
259 | (playstationGamepad.touchpadPrimary.up, .touchpadPrimaryUp),
260 | (playstationGamepad.touchpadPrimary.right, .touchpadPrimaryRight),
261 | (playstationGamepad.touchpadPrimary.left, .touchpadPrimaryLeft),
262 | (playstationGamepad.touchpadPrimary.down, .touchpadPrimaryDown),
263 | (playstationGamepad.touchpadSecondary.up, .touchpadSecondaryUp),
264 | (playstationGamepad.touchpadSecondary.right, .touchpadSecondaryRight),
265 | (playstationGamepad.touchpadSecondary.down, .touchpadSecondaryDown),
266 | (playstationGamepad.touchpadSecondary.left, .touchpadSecondaryLeft)
267 | ]
268 | )
269 | }
270 |
271 | if let playstationGamepad = controller.physicalInputProfile as? GCDualShockGamepad {
272 | buttons.append(
273 | contentsOf: [
274 | (playstationGamepad.touchpadButton, .touchpadButton),
275 | (playstationGamepad.touchpadPrimary.up, .touchpadPrimaryUp),
276 | (playstationGamepad.touchpadPrimary.right, .touchpadPrimaryRight),
277 | (playstationGamepad.touchpadPrimary.left, .touchpadPrimaryLeft),
278 | (playstationGamepad.touchpadPrimary.down, .touchpadPrimaryDown),
279 | (playstationGamepad.touchpadSecondary.up, .touchpadSecondaryUp),
280 | (playstationGamepad.touchpadSecondary.right, .touchpadSecondaryRight),
281 | (playstationGamepad.touchpadSecondary.down, .touchpadSecondaryDown),
282 | (playstationGamepad.touchpadSecondary.left, .touchpadSecondaryLeft)
283 | ]
284 | )
285 | }
286 |
287 | if let xboxGamepad = controller.physicalInputProfile as? GCXboxGamepad {
288 | buttons.append(
289 | contentsOf: [
290 | (xboxGamepad.buttonShare, .shareButton),
291 | (xboxGamepad.paddleButton1, .paddleButton1),
292 | (xboxGamepad.paddleButton2, .paddleButton2),
293 | (xboxGamepad.paddleButton3, .paddleButton3),
294 | (xboxGamepad.paddleButton4, .paddleButton4)
295 | ]
296 | )
297 | }
298 |
299 | for (button, name) in buttons {
300 | button?.valueChangedHandler = { [weak self] (_, _, pressed) in
301 | self?.lastAction = name
302 | self?.eventHandler?(name, pressed, controller)
303 | }
304 | }
305 |
306 | gamepad.leftThumbstick.valueChangedHandler = { (_, xPos, yPos) in
307 | let action: GCKAction = .leftThumbstick(x: xPos, y: yPos)
308 | self.lastAction = action
309 | self.leftThumbstick = action.position
310 | self.eventHandler?(action, false, controller)
311 | }
312 |
313 | gamepad.rightThumbstick.valueChangedHandler = { (_, xPos, yPos) in
314 | let action: GCKAction = .rightThumbstick(x: xPos, y: yPos)
315 | self.lastAction = action
316 | self.rightThumbstick = action.position
317 | self.eventHandler?(action, false, controller)
318 | }
319 | }
320 | }
321 | }
322 |
323 | extension GCColor {
324 | /// Random color
325 | ///
326 | /// - Returns: A random color.
327 | public static var GCKRandom: GCColor {
328 | return GCColor(
329 | red: .random(in: 0...1),
330 | green: .random(in: 0...1),
331 | blue: .random(in: 0...1)
332 | )
333 | }
334 | }
335 |
--------------------------------------------------------------------------------
/Sources/GameControllerKit/GCKControllerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ControllerView.swift
3 | // GCK
4 | //
5 | // Created by Wesley de Groot on 20/08/2024.
6 | //
7 |
8 | import SwiftUI
9 | import GameController
10 |
11 | /// Controller view.
12 | public struct GCKControllerView: View {
13 | // swiftlint:disable:previous type_body_length
14 |
15 | @EnvironmentObject
16 | var GCKit: GameControllerKit
17 |
18 | var action: GCKAction {
19 | GCKit.lastAction
20 | }
21 |
22 | /// Initialize a controller view.
23 | public init() { }
24 |
25 | public var body: some View {
26 | VStack {
27 | // L2, R2
28 | shoulder2
29 | shoulder1
30 |
31 | selectMenu
32 |
33 | HStack {
34 | dPad
35 | if let controller = GCKit.controller,
36 | controller.hasTouchPad {
37 | touchPad
38 | }
39 | buttons
40 | }
41 |
42 | thumbSticks
43 | }
44 | .padding(50)
45 | }
46 |
47 | var shoulder2: some View {
48 | HStack {
49 | Text("L2")
50 | .padding(3)
51 | .frame(width: 75, height: 25)
52 | .background(Rectangle().stroke())
53 | .background(
54 | Rectangle()
55 | .fill()
56 | .foregroundStyle(
57 | action == .leftTrigger ? .yellow : .clear
58 | )
59 | )
60 |
61 | Spacer()
62 | Text("R2")
63 | .padding(3)
64 | .frame(width: 75, height: 25)
65 | .background(Rectangle().stroke())
66 | .background(
67 | Rectangle()
68 | .fill()
69 | .foregroundStyle(
70 | action == .rightTrigger ? .yellow : .clear
71 | )
72 | )
73 | }
74 | }
75 |
76 | var shoulder1: some View {
77 | HStack {
78 | Text("L1")
79 | .padding(3)
80 | .frame(width: 75, height: 25)
81 | .background(Rectangle().stroke())
82 | .background(
83 | Rectangle()
84 | .fill()
85 | .foregroundStyle(
86 | action == .leftShoulder ? .yellow : .clear
87 | )
88 | )
89 |
90 | Spacer()
91 | Text("R1")
92 | .padding(3)
93 | .frame(width: 75, height: 25)
94 | .background(Rectangle().stroke())
95 | .background(
96 | Rectangle()
97 | .fill()
98 | .foregroundStyle(
99 | action == .rightShoulder ? .yellow : .clear
100 | )
101 | )
102 | }
103 | }
104 |
105 | var selectMenu: some View {
106 | HStack {
107 | Spacer()
108 |
109 | Text("O")
110 | .padding(3)
111 | .frame(width: 25, height: 25)
112 | .background(Rectangle().stroke())
113 | .background(
114 | Rectangle()
115 | .fill()
116 | .foregroundStyle(
117 | action == .buttonOptions ? .yellow : .clear
118 | )
119 | )
120 |
121 | Spacer()
122 |
123 | Text("M")
124 | .padding(3)
125 | .frame(width: 25, height: 25)
126 | .background(Rectangle().stroke())
127 | .background(
128 | Rectangle()
129 | .fill()
130 | .foregroundStyle(
131 | action == .buttonMenu ? .yellow : .clear
132 | )
133 | )
134 |
135 | Spacer()
136 | }
137 | }
138 |
139 | var dPad: some View {
140 | VStack {
141 | Text("U")
142 | .padding(3)
143 | .frame(width: 25, height: 25)
144 | .background(Rectangle().stroke())
145 | .background(
146 | Rectangle()
147 | .fill()
148 | .foregroundStyle(
149 | action == .dpadUp ? .yellow : .clear
150 | )
151 | )
152 |
153 | HStack {
154 | Text("L")
155 | .padding(3)
156 | .frame(width: 25, height: 25)
157 | .background(Rectangle().stroke())
158 | .background(
159 | Rectangle()
160 | .fill()
161 | .foregroundStyle(
162 | action == .dpadLeft ? .yellow : .clear
163 | )
164 | )
165 | .padding(.trailing, 25)
166 |
167 | Text("R")
168 | .padding(3)
169 | .frame(width: 25, height: 25)
170 | .background(Rectangle().stroke())
171 | .background(
172 | Rectangle()
173 | .fill()
174 | .foregroundStyle(
175 | action == .dpadRight ? .yellow : .clear
176 | )
177 | )
178 | }
179 |
180 | Text("D")
181 | .padding(3)
182 | .frame(width: 25, height: 25)
183 | .background(Rectangle().stroke())
184 | .background(
185 | Rectangle()
186 | .fill()
187 | .foregroundStyle(
188 | action == .dpadDown ? .yellow : .clear
189 | )
190 | )
191 | }
192 | }
193 |
194 | var touchPad: some View {
195 | HStack {
196 | Spacer()
197 |
198 | Text(
199 | action.touchPadAction
200 | ? String(describing: action)
201 | : "TouchPad"
202 | )
203 | .padding(3)
204 | .frame(minWidth: 100, minHeight: 50)
205 | .background(
206 | Rectangle()
207 | .stroke()
208 | .foregroundStyle(
209 | Color(cgColor: .init(
210 | red: CGFloat(GCKit.controller?.light?.color.red ?? 0),
211 | green: CGFloat(GCKit.controller?.light?.color.green ?? 0),
212 | blue: CGFloat(GCKit.controller?.light?.color.blue ?? 0),
213 | alpha: 1
214 | ))
215 | )
216 | )
217 | .background(
218 | Rectangle()
219 | .fill()
220 | .foregroundStyle(action.touchPadAction ? .yellow : .clear)
221 | )
222 | .padding(.bottom, 50)
223 |
224 | Spacer()
225 | }
226 | }
227 |
228 | var buttons: some View {
229 | VStack {
230 | Text("Y")
231 | .padding(3)
232 | .frame(width: 25, height: 25)
233 | .background(
234 | RoundedRectangle(cornerRadius: 25)
235 | .stroke()
236 | )
237 | .background(
238 | RoundedRectangle(cornerRadius: 25)
239 | .fill()
240 | .foregroundStyle(
241 | action == .buttonY ? .yellow : .clear
242 | )
243 | )
244 |
245 | HStack {
246 | Text("X")
247 | .padding(3)
248 | .frame(width: 25, height: 25)
249 | .background(
250 | RoundedRectangle(cornerRadius: 25)
251 | .stroke()
252 | )
253 | .background(
254 | RoundedRectangle(cornerRadius: 25)
255 | .fill()
256 | .foregroundStyle(
257 | action == .buttonX ? .yellow : .clear
258 | )
259 | )
260 | .padding(.trailing, 25)
261 |
262 | Text("B")
263 | .padding(3)
264 | .frame(width: 25, height: 25)
265 | .background(
266 | RoundedRectangle(cornerRadius: 25)
267 | .stroke()
268 | )
269 | .background(
270 | RoundedRectangle(cornerRadius: 25)
271 | .fill()
272 | .foregroundStyle(
273 | action == .buttonB ? .yellow : .clear
274 | )
275 | )
276 | }
277 |
278 | Text("A")
279 | .padding(3)
280 | .frame(width: 25, height: 25)
281 | .background(
282 | RoundedRectangle(cornerRadius: 25)
283 | .stroke()
284 | )
285 | .background(
286 | RoundedRectangle(cornerRadius: 25)
287 | .fill()
288 | .foregroundStyle(
289 | action == .buttonA ? .yellow : .clear
290 | )
291 | )
292 | }
293 | }
294 |
295 | var thumbSticks: some View {
296 | HStack {
297 | Spacer()
298 |
299 | Text("L")
300 | .padding(3)
301 | .frame(width: 50, height: 50)
302 | .background(
303 | RoundedRectangle(cornerRadius: 25)
304 | .stroke()
305 | )
306 | .background(
307 | RoundedRectangle(cornerRadius: 25)
308 | .fill()
309 | .foregroundStyle(
310 | action == .leftThumbstickButton ? .yellow : .clear
311 | )
312 | )
313 | .overlay {
314 | if case .leftThumbstick = action {
315 | switch action.position {
316 | case .up:
317 | Text("↑")
318 | .padding(.bottom)
319 |
320 | case .upRight:
321 | Text("↗")
322 | .padding(.bottom)
323 | .padding(.leading)
324 |
325 | case .right:
326 | Text("→")
327 | .padding(.leading)
328 |
329 | case .downRight:
330 | Text("↘")
331 | .padding(.top)
332 | .padding(.leading)
333 |
334 | case .down:
335 | Text("↓")
336 | .padding(.top)
337 |
338 | case .downLeft:
339 | Text("↙")
340 | .padding(.top)
341 | .padding(.trailing)
342 |
343 | case .left:
344 | Text("←")
345 | .padding(.trailing)
346 |
347 | case .upLeft:
348 | Text("↖")
349 | .padding(.bottom)
350 | .padding(.trailing)
351 |
352 | default:
353 | Text("")
354 | }
355 | }
356 | }
357 |
358 | Spacer()
359 |
360 | Text("H")
361 | .padding(3)
362 | .frame(width: 25, height: 25)
363 | .background(
364 | RoundedRectangle(cornerRadius: 25)
365 | .stroke()
366 | )
367 | .background(
368 | RoundedRectangle(cornerRadius: 25)
369 | .fill()
370 | .foregroundStyle(
371 | action == .buttonHome ? .yellow : .clear
372 | )
373 | )
374 |
375 | Spacer()
376 |
377 | Text("R")
378 | .padding(3)
379 | .frame(width: 50, height: 50)
380 | .background(
381 | RoundedRectangle(cornerRadius: 25)
382 | .stroke()
383 | )
384 | .background(
385 | RoundedRectangle(cornerRadius: 25)
386 | .fill()
387 | .foregroundStyle(
388 | action == .rightThumbstickButton ? .yellow : .clear
389 | )
390 | )
391 | .overlay {
392 | if case .rightThumbstick = action {
393 | switch action.position {
394 | case .up:
395 | Text("↑")
396 | .padding(.bottom)
397 |
398 | case .upRight:
399 | Text("↗")
400 | .padding(.bottom)
401 | .padding(.leading)
402 |
403 | case .right:
404 | Text("→")
405 | .padding(.leading)
406 |
407 | case .downRight:
408 | Text("↘")
409 | .padding(.top)
410 | .padding(.leading)
411 |
412 | case .down:
413 | Text("↓")
414 | .padding(.top)
415 |
416 | case .downLeft:
417 | Text("↙")
418 | .padding(.top)
419 | .padding(.trailing)
420 |
421 | case .left:
422 | Text("←")
423 | .padding(.trailing)
424 |
425 | case .upLeft:
426 | Text("↖")
427 | .padding(.bottom)
428 | .padding(.trailing)
429 |
430 | default:
431 | Text("")
432 | }
433 | }
434 | }
435 |
436 | Spacer()
437 | }
438 | }
439 |
440 | var paddleButtons: some View {
441 | VStack {
442 | HStack {
443 | Text("Paddle 1")
444 | .padding(3)
445 | .frame(width: 75, height: 25)
446 | .background(Rectangle().stroke())
447 | .background(
448 | Rectangle()
449 | .fill()
450 | .foregroundStyle(
451 | action == .paddleButton1 ? .yellow : .clear
452 | )
453 | )
454 |
455 | Spacer()
456 | Text("Paddle 2")
457 | .padding(3)
458 | .frame(width: 75, height: 25)
459 | .background(Rectangle().stroke())
460 | .background(
461 | Rectangle()
462 | .fill()
463 | .foregroundStyle(
464 | action == .paddleButton2 ? .yellow : .clear
465 | )
466 | )
467 | }
468 | HStack {
469 | Text("Paddle 3")
470 | .padding(3)
471 | .frame(width: 75, height: 25)
472 | .background(Rectangle().stroke())
473 | .background(
474 | Rectangle()
475 | .fill()
476 | .foregroundStyle(
477 | action == .paddleButton3 ? .yellow : .clear
478 | )
479 | )
480 |
481 | Spacer()
482 | Text("Paddle 4")
483 | .padding(3)
484 | .frame(width: 75, height: 25)
485 | .background(Rectangle().stroke())
486 | .background(
487 | Rectangle()
488 | .fill()
489 | .foregroundStyle(
490 | action == .paddleButton4 ? .yellow : .clear
491 | )
492 | )
493 | }
494 | }
495 | }
496 | }
497 |
498 | struct GCKControllerViewPreview: PreviewProvider {
499 | static var previews: some View {
500 | GCKControllerView()
501 | .environmentObject(GameControllerKit())
502 | }
503 | }
504 | // swiftlint:disable:this file_length
505 |
--------------------------------------------------------------------------------