diff --git a/Documentation/Layout.md b/Documentation/Layout.md
index 38e74c69..a1e106ae 100644
--- a/Documentation/Layout.md
+++ b/Documentation/Layout.md
@@ -1,4 +1,17 @@
-# Layout
+# Layout Basics
+
+The `StreamDeckLayout` view is a fundamental component for building layouts for Stream Deck devices using SwiftUI. It provides a way to define the key area view with its keys and window view with its dials for a Stream Deck layout. This layout can be used to draw a customized layout onto a Stream Deck device and to recognize Stream Deck interactions in the SwiftUI way.
+
+A `StreamDeckLayout` combined with the `@StreamDeckView` Macro does the heavy lifting for you by automatically recognizing view updates, and triggering an update of the rendered image on your Stream Deck device.
+
+The general structure of `StreamDeckLayout` is as follows:
+```
+StreamDeckLayout
+└───keyArea: StreamDeckKeyAreaLayout
+│ └───StreamDeckKeyView
+└───windowArea: StreamDeckDialAreaLayout
+ └───StreamDeckDialView
+```
@@ -6,4 +19,112 @@
-[...] Values will differ, depending on which view is the next parent in hierarchy. So in a `keyAreaView` of `StreamDeckLayout`, the `size` will reflect the size of the whole area. While in the `keyView` of a `StreamDeckKeypadLayout`, the `size` will reflect the size of the key canvas.
\ No newline at end of file
+> [!NOTE]
+> The window area is only available for the Stream Deck + and will be ignored for other device types.
+
+## Usage
+To use `StreamDeckLayout`, create an instance of it by specifying the key area and window views. Then, provide this instance to either the `StreamDeckSession.setUp` method or the `StreamDeck.render` method.
+
+### Example
+
+Here's an example of how to create a basic static `StreamDeckLayout`. For examples on how to create a stateful and an animated layout, see [Stateful Layout](Layout_Stateful.md) and [Animated Layout](Layout_Animated.md), respectively.
+
+```swift
+import SwiftUI
+import StreamDeckKit
+
+@StreamDeckView
+struct StatelessStreamDeckLayout {
+
+ var streamDeckBody: some View {
+ StreamDeckLayout {
+ // Define key area
+ // Use StreamDeckKeyAreaLayout for rendering separate keys
+ StreamDeckKeyAreaLayout { context in
+ // Define content for each key.
+ // StreamDeckKeyAreaLayout provides a context for each available key,
+ // and StreamDeckKeyView provides a callback for the key action
+ // Example:
+ StreamDeckKeyView { pressed in
+ print("pressed \(pressed)")
+ } content: {
+ Text("\(context.index)")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.teal)
+ }
+ }.background(.purple)
+ } windowArea: {
+ // Define window area
+ // Use StreamDeckDialAreaLayout for rendering separate parts of the display
+ StreamDeckDialAreaLayout { context in
+ // Define content for each dial
+ // StreamDeckDialAreaLayout provides a context for each available dial,
+ // and StreamDeckDialView provides callbacks for the dial actions
+ // Example:
+ StreamDeckDialView { rotations in
+ print("dial rotated \(rotations)")
+ } press: { pressed in
+ print("pressed \(pressed)")
+ } touch: { location in
+ print("touched at \(location)")
+ } content: {
+ Text("\(context.index)")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(white: Double(context.index) / 5 + 0.5))
+ }
+ }
+ }.background(.indigo)
+ }
+
+}
+
+```
+
+Depending on the device, the outcome will look like this:
+
+
+
+ Mini
+
+ Note: On the Stream Deck Mini device, you can not set a complete screen image. However, the purple background on the key area would be visible if the keys had transparent areas.
+
+
+
+
+ Classic
+
+
+
+
+
+ XL
+
+
+
+
+ Plus
+
+
+
+
+
+
+
+### SwiftUI Preview
+
+You can use the provided `StreamDeckSimulator/PreviewView` (see [Simulator](Simulator.md)) to view your layouts in the SwiftUI Preview canvas.
+```swift
+import StreamDeckSimulator
+
+#Preview("Stream Deck +") {
+ StreamDeckSimulator.PreviewView(streamDeck: .plus) {
+ StatelessStreamDeckLayout()
+ }
+}
+
+#Preview("Stream Deck Classic") {
+ StreamDeckSimulator.PreviewView(streamDeck: .regular) {
+ StatelessStreamDeckLayout()
+ }
+}
+```
diff --git a/Documentation/Layout_Animated.md b/Documentation/Layout_Animated.md
new file mode 100644
index 00000000..83b90c06
--- /dev/null
+++ b/Documentation/Layout_Animated.md
@@ -0,0 +1,157 @@
+# Animated Layout
+
+As described in [Stateful Layout](Layout.md), the `StreamDeckLayout` combined with the `@StreamDeckView` Macro is used to automatically update the image rendered on your Stream Deck Device on view state changes.
+
+Due to the underlying transformation of an SwiftUI view to an image that can be rendered on your Stream Deck device, SwiftUI's animations do not work as you might expect. However, the following example shows how you can create animations regardless, leveraging incremental state changes over time.
+
+## Example
+
+Here's an example of how to create a basic animated `StreamDeckLayout` which changes the appearance on events like key presses or dial rotations with animations.
+
+For Stream Deck +, this layout will be rendered and react to interactions as follows:
+
+
+
+
+
+```swift
+import StreamDeckKit
+import SwiftUI
+
+@StreamDeckView
+struct AnimatedStreamDeckLayout {
+
+ var streamDeckBody: some View {
+ StreamDeckLayout {
+ StreamDeckKeyAreaLayout { _ in
+ // To react to state changes within each StreamDeckKeyView, extract the view, just as you normally would in SwiftUI
+ // Example:
+ MyKeyView()
+ }
+ } windowArea: {
+ StreamDeckDialAreaLayout { _ in
+ // To react to state changes within each StreamDeckDialView, extract the view, just as you normally would in SwiftUI
+ // Example:
+ MyDialView()
+ }
+ }
+ }
+
+ @StreamDeckView
+ struct MyKeyView {
+
+ @State private var isPressed: Bool?
+ @State private var scale: CGFloat = 1.0
+ @State private var rotationDegree: Double = .zero
+
+ var streamDeckBody: some View {
+ StreamDeckKeyView { pressed in
+ self.isPressed = pressed
+ } content: {
+ VStack {
+ Text("\(viewIndex)") // `viewIndex` is provided by the `@StreamDeckView` macro
+ Text(isPressed == true ? "Key down" : "Key up")
+ }
+ .scaleEffect(scale) // Update the scale depending on the state
+ .rotationEffect(.degrees(rotationDegree)) // Update the rotation depending on the state
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(isPressed == true ? .yellow.opacity(0.5) : .yellow)
+ }
+ .task(id: isPressed) {
+ // Animate the scale effect by applying different scale values over time
+ func apply(_ scale: CGFloat) async {
+ guard !Task.isCancelled else { return }
+ self.scale = scale
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+
+ let scales: [CGFloat] = [1, 0.9, 0.8, 0.7]
+ if isPressed == true {
+ for scale in scales {
+ await apply(scale)
+ }
+ } else if isPressed == false {
+ for scale in scales.reversed() {
+ await apply(scale)
+ }
+ }
+ }
+ .task(id: isPressed) {
+ // Animate the rotation effect by applying different rotation degree values over time
+ func apply(_ degree: Double) async {
+ guard !Task.isCancelled else { return }
+ self.rotationDegree = degree
+ try? await Task.sleep(for: .milliseconds(50))
+ }
+
+ let rotationDegrees = [0, -10.0, -20, -30, -20, -10, 0, 10, 20, 30, 20, 10, 0]
+ if isPressed == true {
+ for degree in rotationDegrees {
+ await apply(degree)
+ }
+ } else if isPressed == false {
+ for degree in rotationDegrees.reversed() {
+ await apply(degree)
+ }
+ }
+ }
+ }
+ }
+
+ @StreamDeckView
+ struct MyDialView {
+
+ @State private var isPressed: Bool?
+
+ @State private var position: CGPoint = .zero
+ @State private var targetPosition: CGPoint?
+
+ var streamDeckBody: some View {
+ StreamDeckDialView { rotations in
+ self.position.x += CGFloat(rotations)
+ } press: { pressed in
+ self.isPressed = pressed
+ } touch: { location in
+ self.targetPosition = location
+ } content: {
+ Text("\(viewIndex)")
+ .position(position) // Update the position depending on the state
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(white: Double(viewIndex) / 5 + 0.5))
+ }
+ .task(id: targetPosition) {
+ // Animate the change of the position by applying different position values over time
+ // Calculate three points in between the current position and the target position
+ guard let targetPosition = targetPosition else { return }
+
+ func calculateCenter(_ pointA: CGPoint, _ pointB: CGPoint) -> CGPoint {
+ return .init(x: (pointA.x + pointB.x) / 2, y: (pointA.y + pointB.y) / 2)
+ }
+ let currentPosition = position
+ let centerPosition = calculateCenter(currentPosition, targetPosition)
+ let firstQuarterPosition = calculateCenter(currentPosition, centerPosition)
+ let thirdQuarterPosition = calculateCenter(centerPosition, targetPosition)
+
+ func apply(_ position: CGPoint) async {
+ guard !Task.isCancelled else { return }
+ self.position = position
+ try? await Task.sleep(for: .milliseconds(50))
+ }
+ for position in [firstQuarterPosition, centerPosition, thirdQuarterPosition, targetPosition] {
+ await apply(position)
+ }
+ }
+ .task(id: isPressed) {
+ // Resets position to center initially, and when pressed
+ if isPressed == nil || isPressed == true {
+ self.position = CGPoint(
+ x: viewSize.width / 2,
+ y: viewSize.height / 2 // `viewSize` is provided by the `@StreamDeckView` macro
+ )
+ }
+ }
+ }
+ }
+
+}
+```
diff --git a/Documentation/Layout_Stateful.md b/Documentation/Layout_Stateful.md
new file mode 100644
index 00000000..e4da5a07
--- /dev/null
+++ b/Documentation/Layout_Stateful.md
@@ -0,0 +1,91 @@
+# Stateful Layout
+
+As described in [Layout](Layout.md), the `StreamDeckLayout` combined with the `@StreamDeckView` Macro does the heavy lifting for you by automatically recognizing view updates, and triggering an update of the rendered image on your Stream Deck device.
+
+To update your `StreamDeckLayout` on events like key presses or dial rotations, the view that should react to state changes needs to be extracted in its own view, just as you would normally do with SwiftUI. If that view is annotated with the `@StreamDeckView` Macro, context-dependent variables like the `viewIndex` and `viewSize` are available in that view's scope.
+
+## Example
+
+Here's an example of how to create a basic stateful `StreamDeckLayout` which changes the appearance on events like key presses or dial rotations.
+
+For Stream Deck +, this layout will be rendered and react to interactions as follows:
+
+
+
+
+
+
+```swift
+import StreamDeckKit
+import SwiftUI
+
+@StreamDeckView
+struct StatefulStreamDeckLayout {
+
+ var streamDeckBody: some View {
+ StreamDeckLayout {
+ StreamDeckKeyAreaLayout { _ in
+ // To react to state changes within each StreamDeckKeyView, extract the view, just as you normally would in SwiftUI
+ // Example:
+ MyKeyView()
+ }
+ } windowArea: {
+ StreamDeckDialAreaLayout { _ in
+ // To react to state changes within each StreamDeckDialView, extract the view, just as you normally would in SwiftUI
+ // Example:
+ MyDialView()
+ }
+ }
+ }
+
+ @StreamDeckView
+ struct MyKeyView {
+
+ @State private var isPressed: Bool = false
+
+ var streamDeckBody: some View {
+ StreamDeckKeyView { pressed in
+ self.isPressed = pressed
+ } content: {
+ VStack {
+ Text("\(viewIndex)") // `viewIndex` is provided by the `@StreamDeckView` macro
+ Text(isPressed ? "Key down" : "Key up")
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(isPressed ? .purple.opacity(0.5) : .purple) // Updating the background depending on the state
+ }
+ }
+ }
+
+ @StreamDeckView
+ struct MyDialView {
+
+ @State private var offset: CGSize = .zero
+ @State private var scale: CGFloat = 1
+
+ var streamDeckBody: some View {
+ StreamDeckDialView { rotations in
+ self.scale = min(max(scale + CGFloat(rotations) / 10, 0.5), 5)
+ } press: { pressed in
+ if pressed {
+ self.scale = 1
+ self.offset = .zero
+ }
+ } touch: { location in
+ self.offset = CGSize(
+ width: location.x - viewSize.width / 2,
+ height: location.y - viewSize.height / 2 // `viewSize` is provided by the `@StreamDeckView` macro
+ )
+ } content: {
+ Text("\(viewIndex)")
+ .scaleEffect(scale) // Updating the scale depending on the state
+ .offset(offset) // Updating the offset depending on the state
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(white: Double(viewIndex) / 5 + 0.5))
+ }
+ }
+ }
+
+}
+
+```
\ No newline at end of file
diff --git a/Documentation/_images/StreamDeckLayout.dark.svg b/Documentation/_images/StreamDeckLayout.dark.svg
index f958a5bf..bed2eddb 100644
--- a/Documentation/_images/StreamDeckLayout.dark.svg
+++ b/Documentation/_images/StreamDeckLayout.dark.svg
@@ -1,33 +1,38 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-Window
-Screen
+
+
+
-Key
-Key Area
-
-Dial
+
+
+
+
+
-
+
diff --git a/Documentation/_images/StreamDeckLayout.light.svg b/Documentation/_images/StreamDeckLayout.light.svg
index 4caa9ec2..d2203688 100644
--- a/Documentation/_images/StreamDeckLayout.light.svg
+++ b/Documentation/_images/StreamDeckLayout.light.svg
@@ -1,44 +1,49 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-Window
-Screen
+
+
+
-Key
-Key Area
-
-Dial
+
+
+
+
+
-
+
-
-
+
+
-
+
diff --git a/Documentation/_images/layout_animated_sd_plus_device.gif b/Documentation/_images/layout_animated_sd_plus_device.gif
new file mode 100644
index 00000000..3e45510b
Binary files /dev/null and b/Documentation/_images/layout_animated_sd_plus_device.gif differ
diff --git a/Documentation/_images/layout_sd_classic.png b/Documentation/_images/layout_sd_classic.png
new file mode 100644
index 00000000..e6d17454
Binary files /dev/null and b/Documentation/_images/layout_sd_classic.png differ
diff --git a/Documentation/_images/layout_sd_classic_device.png b/Documentation/_images/layout_sd_classic_device.png
new file mode 100644
index 00000000..b4c1cd00
Binary files /dev/null and b/Documentation/_images/layout_sd_classic_device.png differ
diff --git a/Documentation/_images/layout_sd_mini.png b/Documentation/_images/layout_sd_mini.png
new file mode 100644
index 00000000..d649397e
Binary files /dev/null and b/Documentation/_images/layout_sd_mini.png differ
diff --git a/Documentation/_images/layout_sd_mini_device.png b/Documentation/_images/layout_sd_mini_device.png
new file mode 100644
index 00000000..08ef64c7
Binary files /dev/null and b/Documentation/_images/layout_sd_mini_device.png differ
diff --git a/Documentation/_images/layout_sd_plus.png b/Documentation/_images/layout_sd_plus.png
new file mode 100644
index 00000000..0f1a91ae
Binary files /dev/null and b/Documentation/_images/layout_sd_plus.png differ
diff --git a/Documentation/_images/layout_sd_plus_device.png b/Documentation/_images/layout_sd_plus_device.png
new file mode 100644
index 00000000..8a71f09e
Binary files /dev/null and b/Documentation/_images/layout_sd_plus_device.png differ
diff --git a/Documentation/_images/layout_sd_xl.png b/Documentation/_images/layout_sd_xl.png
new file mode 100644
index 00000000..468f7e50
Binary files /dev/null and b/Documentation/_images/layout_sd_xl.png differ
diff --git a/Documentation/_images/layout_sd_xl_device.png b/Documentation/_images/layout_sd_xl_device.png
new file mode 100644
index 00000000..398cf01a
Binary files /dev/null and b/Documentation/_images/layout_sd_xl_device.png differ
diff --git a/Documentation/_images/layout_stateful_sd_plus_device.gif b/Documentation/_images/layout_stateful_sd_plus_device.gif
new file mode 100644
index 00000000..4cd3cfb2
Binary files /dev/null and b/Documentation/_images/layout_stateful_sd_plus_device.gif differ
diff --git a/Example/Example App/ContentView.swift b/Example/Example App/ContentView.swift
index 71292b6c..418b4610 100644
--- a/Example/Example App/ContentView.swift
+++ b/Example/Example App/ContentView.swift
@@ -5,28 +5,55 @@
// Created by Roman Schlagowsky on 28.12.23.
//
-import SwiftUI
import StreamDeckKit
+import SwiftUI
struct ContentView: View {
+
+ @Environment(\.exampleDataModel) var dataModel
+
@State private var stateDescription: String = StreamDeckSession.State.idle.debugDescription
@State private var devices: [StreamDeck] = []
- init() {
- StreamDeckSession.setUp { _ in
- StreamDeckLayoutView()
+ var body: some View {
+ @Bindable var dataModel = dataModel
+ TabView(selection: $dataModel.selectedExample) {
+ sessionStateView
+ .tabItem {
+ Label("1. Example - Stateless", systemImage: "figure")
+ }
+ .tag(Example.stateless)
+
+ sessionStateView
+ .tabItem {
+ Label("2. Example - Stateful", systemImage: "figure.walk")
+ }
+ .tag(Example.stateful)
+
+ sessionStateView
+ .tabItem {
+ Label("3. Example - Animated", systemImage: "figure.stairs")
+ }
+ .tag(Example.animated)
}
}
- var body: some View {
+ var sessionStateView: some View {
VStack {
+ switch dataModel.selectedExample {
+ case .stateless: Text("1. Example - Stateless").font(.title).padding()
+ case .stateful: Text("2. Example - Stateful").font(.title).padding()
+ case .animated: Text("3. Example - Animated").font(.title).padding()
+ }
Text("Session State: \(stateDescription)")
if devices.isEmpty {
Text("Please connect a Stream Deck device!")
- Text("or")
- Button("Start the Stream Deck Simulator") {
- StreamDeckSimulator.show(streamDeck: .mini)
- }
+ #if DEBUG
+ Text("or")
+ Button("Start the Stream Deck Simulator") {
+ StreamDeckSimulator.show(streamDeck: .mini)
+ }
+ #endif
} else {
ForEach(devices) { device in
VStack(alignment: .leading) {
@@ -45,6 +72,7 @@ struct ContentView: View {
}
}
}
+ Spacer()
}
.padding()
.onReceive(StreamDeckSession.instance.$state) { stateDescription = $0.debugDescription }
@@ -52,23 +80,11 @@ struct ContentView: View {
}
}
-#Preview {
- ContentView()
-}
-
-// MARK: - Simulator preview
-
#if DEBUG
-import StreamDeckSimulator
-
-#Preview("With simulator attached") {
- StreamDeckSession.setUp { _ in
- StreamDeckLayoutView()
- }
+ import StreamDeckSimulator
- return VStack {
+ #Preview {
ContentView()
- StreamDeckSimulator.PreviewView(streamDeck: .mini)
}
-}
+
#endif
diff --git a/Example/Example App/EnvironmentValues.swift b/Example/Example App/EnvironmentValues.swift
new file mode 100644
index 00000000..27f2b1ff
--- /dev/null
+++ b/Example/Example App/EnvironmentValues.swift
@@ -0,0 +1,21 @@
+//
+// EnvironmentValues.swift
+// Example App
+//
+// Created by Christiane Göhring on 21.02.24.
+//
+
+import SwiftUI
+
+extension EnvironmentValues {
+
+ var exampleDataModel: ExampleDataModel {
+ get { return self[ExampleDataModelKey.self] }
+ set { self[ExampleDataModelKey.self] = newValue }
+ }
+
+}
+
+private struct ExampleDataModelKey: EnvironmentKey {
+ static let defaultValue = ExampleDataModel()
+}
diff --git a/Example/Example App/Examples/1_StatelessStreamDeckLayout.swift b/Example/Example App/Examples/1_StatelessStreamDeckLayout.swift
new file mode 100644
index 00000000..43c29596
--- /dev/null
+++ b/Example/Example App/Examples/1_StatelessStreamDeckLayout.swift
@@ -0,0 +1,72 @@
+//
+// StatelessStreamDeckLayout.swift
+// Example App
+//
+// Created by Christiane Göhring on 20.02.24.
+//
+
+import StreamDeckKit
+import SwiftUI
+
+@StreamDeckView
+struct StatelessStreamDeckLayout {
+
+ var streamDeckBody: some View {
+ StreamDeckLayout {
+ // Define key area
+ // Use StreamDeckKeyAreaLayout for rendering separate keys
+ StreamDeckKeyAreaLayout { context in
+ // Define content for each key.
+ // StreamDeckKeyAreaLayout provides a context for each available key,
+ // and StreamDeckKeyView provides a callback for the key action
+ // Example:
+ StreamDeckKeyView { pressed in
+ print("pressed \(pressed)")
+ } content: {
+ Text("\(context.index)")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.teal)
+ }
+ }.background(.purple)
+ } windowArea: {
+ // Define window area
+ // Use StreamDeckDialAreaLayout for rendering separate parts of the display
+ StreamDeckDialAreaLayout { context in
+ // Define content for each dial
+ // StreamDeckDialAreaLayout provides a context for each available dial,
+ // and StreamDeckDialView provides callbacks for the dial actions
+ // Example:
+ StreamDeckDialView { rotations in
+ print("dial rotated \(rotations)")
+ } press: { pressed in
+ print("pressed \(pressed)")
+ } touch: { location in
+ print("touched at \(location)")
+ } content: {
+ Text("\(context.index)")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(white: Double(context.index) / 5 + 0.5))
+ }
+ }
+ }.background(.indigo)
+ }
+
+}
+
+#if DEBUG
+
+ import StreamDeckSimulator
+
+ #Preview("Stream Deck +") {
+ StreamDeckSimulator.PreviewView(streamDeck: .plus) {
+ StatelessStreamDeckLayout()
+ }
+ }
+
+ #Preview("Stream Deck Classic") {
+ StreamDeckSimulator.PreviewView(streamDeck: .regular) {
+ StatelessStreamDeckLayout()
+ }
+ }
+
+#endif
diff --git a/Example/Example App/Examples/2_StatefulStreamDeckLayout.swift b/Example/Example App/Examples/2_StatefulStreamDeckLayout.swift
new file mode 100644
index 00000000..df138475
--- /dev/null
+++ b/Example/Example App/Examples/2_StatefulStreamDeckLayout.swift
@@ -0,0 +1,96 @@
+//
+// StatefulStreamDeckLayout.swift
+// Example App
+//
+// Created by Christiane Göhring on 20.02.24.
+//
+
+import StreamDeckKit
+import SwiftUI
+
+@StreamDeckView
+struct StatefulStreamDeckLayout {
+
+ var streamDeckBody: some View {
+ StreamDeckLayout {
+ StreamDeckKeyAreaLayout { _ in
+ // To react to state changes within each StreamDeckKeyView, extract the view, just as you normally would in SwiftUI
+ // Example:
+ MyKeyView()
+ }
+ } windowArea: {
+ StreamDeckDialAreaLayout { _ in
+ // To react to state changes within each StreamDeckDialView, extract the view, just as you normally would in SwiftUI
+ // Example:
+ MyDialView()
+ }
+ }
+ }
+
+ @StreamDeckView
+ struct MyKeyView {
+
+ @State private var isPressed: Bool = false
+
+ var streamDeckBody: some View {
+ StreamDeckKeyView { pressed in
+ self.isPressed = pressed
+ } content: {
+ VStack {
+ Text("\(viewIndex)") // `viewIndex` is provided by the `@StreamDeckView` macro
+ Text(isPressed ? "Key down" : "Key up")
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(isPressed ? .purple.opacity(0.5) : .purple) // Updating the background depending on the state
+ }
+ }
+ }
+
+ @StreamDeckView
+ struct MyDialView {
+
+ @State private var offset: CGSize = .zero
+ @State private var scale: CGFloat = 1
+
+ var streamDeckBody: some View {
+ StreamDeckDialView { rotations in
+ self.scale = min(max(scale + CGFloat(rotations) / 10, 0.5), 5)
+ } press: { pressed in
+ if pressed {
+ self.scale = 1
+ self.offset = .zero
+ }
+ } touch: { location in
+ self.offset = CGSize(
+ width: location.x - viewSize.width / 2,
+ height: location.y - viewSize.height / 2 // `viewSize` is provided by the `@StreamDeckView` macro
+ )
+ } content: {
+ Text("\(viewIndex)")
+ .scaleEffect(scale) // Updating the scale depending on the state
+ .offset(offset) // Updating the offset depending on the state
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(white: Double(viewIndex) / 5 + 0.5))
+ }
+ }
+ }
+
+}
+
+#if DEBUG
+
+ import StreamDeckSimulator
+
+ #Preview("Stream Deck +") {
+ StreamDeckSimulator.PreviewView(streamDeck: .plus) {
+ StatefulStreamDeckLayout()
+ }
+ }
+
+ #Preview("Stream Deck Classic") {
+ StreamDeckSimulator.PreviewView(streamDeck: .xl) {
+ StatefulStreamDeckLayout()
+ }
+ }
+
+#endif
diff --git a/Example/Example App/Examples/3_AnimatedStreamDeckLayout.swift b/Example/Example App/Examples/3_AnimatedStreamDeckLayout.swift
new file mode 100644
index 00000000..f23c7cc9
--- /dev/null
+++ b/Example/Example App/Examples/3_AnimatedStreamDeckLayout.swift
@@ -0,0 +1,164 @@
+//
+// 3_AnimatedStreamDeckLayout.swift
+// Example App
+//
+// Created by Christiane Göhring on 21.02.24.
+//
+
+import StreamDeckKit
+import SwiftUI
+
+@StreamDeckView
+struct AnimatedStreamDeckLayout {
+
+ var streamDeckBody: some View {
+ StreamDeckLayout {
+ StreamDeckKeyAreaLayout { _ in
+ // To react to state changes within each StreamDeckKeyView, extract the view, just as you normally would in SwiftUI
+ // Example:
+ MyKeyView()
+ }
+ } windowArea: {
+ StreamDeckDialAreaLayout { _ in
+ // To react to state changes within each StreamDeckDialView, extract the view, just as you normally would in SwiftUI
+ // Example:
+ MyDialView()
+ }
+ }
+ }
+
+ @StreamDeckView
+ struct MyKeyView {
+
+ @State private var isPressed: Bool?
+ @State private var scale: CGFloat = 1.0
+ @State private var rotationDegree: Double = .zero
+
+ var streamDeckBody: some View {
+ StreamDeckKeyView { pressed in
+ self.isPressed = pressed
+ } content: {
+ VStack {
+ Text("\(viewIndex)") // `viewIndex` is provided by the `@StreamDeckView` macro
+ Text(isPressed == true ? "Key down" : "Key up")
+ }
+ .scaleEffect(scale) // Updating the scale depending on the state
+ .rotationEffect(.degrees(rotationDegree)) // Updating the rotation depending on the state
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(isPressed == true ? .yellow.opacity(0.5) : .yellow)
+ }
+ .task(id: isPressed) {
+ // Animate the scale effect by applying different scale values over time
+ func apply(_ scale: CGFloat) async {
+ guard !Task.isCancelled else { return }
+ self.scale = scale
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+
+ let scales: [CGFloat] = [1, 0.9, 0.8, 0.7]
+ if isPressed == true {
+ for scale in scales {
+ await apply(scale)
+ }
+ } else if isPressed == false {
+ for scale in scales.reversed() {
+ await apply(scale)
+ }
+ }
+ }
+ .task(id: isPressed) {
+ // Animate the rotation effect by applying different rotation degree values over time
+ func apply(_ degree: Double) async {
+ guard !Task.isCancelled else { return }
+ self.rotationDegree = degree
+ try? await Task.sleep(for: .milliseconds(50))
+ }
+
+ let rotationDegrees = [0, -10.0, -20, -30, -20, -10, 0, 10, 20, 30, 20, 10, 0]
+ if isPressed == true {
+ for degree in rotationDegrees {
+ await apply(degree)
+ }
+ } else if isPressed == false {
+ for degree in rotationDegrees.reversed() {
+ await apply(degree)
+ }
+ }
+ }
+ }
+ }
+
+ @StreamDeckView
+ struct MyDialView {
+
+ @State private var isPressed: Bool?
+
+ @State private var position: CGPoint = .zero
+ @State private var targetPosition: CGPoint?
+
+ var streamDeckBody: some View {
+ StreamDeckDialView { rotations in
+ self.position.x += CGFloat(rotations)
+ } press: { pressed in
+ self.isPressed = pressed
+ } touch: { location in
+ self.targetPosition = location
+ } content: {
+ Text("\(viewIndex)")
+ .position(position) // Updating the position depending on the state
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(white: Double(viewIndex) / 5 + 0.5))
+ }
+ .task(id: targetPosition) {
+ // Animate the change of the position by applying different position values over time
+ // Calculate three points in between the current position and the target position
+ guard let targetPosition = targetPosition else { return }
+
+ func calculateCenter(_ pointA: CGPoint, _ pointB: CGPoint) -> CGPoint {
+ return .init(x: (pointA.x + pointB.x) / 2, y: (pointA.y + pointB.y) / 2)
+ }
+ let currentPosition = position
+ let centerPosition = calculateCenter(currentPosition, targetPosition)
+ let firstQuarterPosition = calculateCenter(currentPosition, centerPosition)
+ let thirdQuarterPosition = calculateCenter(centerPosition, targetPosition)
+
+ func apply(_ position: CGPoint) async {
+ guard !Task.isCancelled else { return }
+ self.position = position
+ try? await Task.sleep(for: .milliseconds(50))
+ }
+ for position in [firstQuarterPosition, centerPosition, thirdQuarterPosition, targetPosition] {
+ await apply(position)
+ }
+ }
+ .task(id: isPressed) {
+ // Resets position to center initially, and when pressed
+ if isPressed == nil || isPressed == true {
+ self.position = CGPoint(
+ x: viewSize.width / 2,
+ y: viewSize.height / 2 // `viewSize` is provided by the `@StreamDeckView` macro
+ )
+ }
+ }
+ }
+ }
+
+}
+
+#if DEBUG
+
+ import StreamDeckSimulator
+
+ #Preview("Stream Deck +") {
+ StreamDeckSimulator.PreviewView(streamDeck: .plus) {
+ AnimatedStreamDeckLayout()
+ }
+ }
+
+ #Preview("Stream Deck Classic") {
+ StreamDeckSimulator.PreviewView(streamDeck: .mini) {
+ AnimatedStreamDeckLayout()
+ }
+ }
+
+#endif
diff --git a/Example/Example App/Examples/BaseStreamDeckView.swift b/Example/Example App/Examples/BaseStreamDeckView.swift
new file mode 100644
index 00000000..dcc79e58
--- /dev/null
+++ b/Example/Example App/Examples/BaseStreamDeckView.swift
@@ -0,0 +1,27 @@
+//
+// BaseStreamDeckView.swift
+// Example App
+//
+// Created by Christiane Göhring on 21.02.24.
+//
+
+import StreamDeckKit
+import SwiftUI
+
+@StreamDeckView
+struct BaseStreamDeckView: View {
+ @Environment(\.exampleDataModel) var dataModel
+
+ @ViewBuilder
+ var streamDeckBody: some View {
+ switch dataModel.selectedExample {
+ case .stateless:
+ StatelessStreamDeckLayout()
+ case .stateful:
+ StatefulStreamDeckLayout()
+ case .animated:
+ AnimatedStreamDeckLayout()
+ }
+ }
+
+}
diff --git a/Example/Example App/Examples/ExampleDataModel.swift b/Example/Example App/Examples/ExampleDataModel.swift
new file mode 100644
index 00000000..15f84dbe
--- /dev/null
+++ b/Example/Example App/Examples/ExampleDataModel.swift
@@ -0,0 +1,19 @@
+//
+// ExampleDataModel.swift
+// Example App
+//
+// Created by Alexander Jentz on 20.02.24.
+//
+
+import SwiftUI
+
+enum Example {
+ case stateless
+ case stateful
+ case animated
+}
+
+@Observable
+final class ExampleDataModel {
+ var selectedExample: Example = .stateless
+}
diff --git a/Example/Example App/StreamDeckKitExampleApp.swift b/Example/Example App/StreamDeckKitExampleApp.swift
index d6581a27..777eb4eb 100644
--- a/Example/Example App/StreamDeckKitExampleApp.swift
+++ b/Example/Example App/StreamDeckKitExampleApp.swift
@@ -11,10 +11,13 @@ import SwiftUI
@main
struct StreamDeckKitExampleApp: App {
+
init() {
- // Remove to disable logging
- streamDeckLoggingHandler = { type, message in
- os_log(type, "\(message)")
+ // Uncomment the next line to enable StreamDeckKit internal logging.
+ // streamDeckLoggingHandler = { os_log($0, "\($1)") }
+
+ StreamDeckSession.setUp { _ in
+ BaseStreamDeckView()
}
}
@@ -24,11 +27,3 @@ struct StreamDeckKitExampleApp: App {
}
}
}
-
-var emojis: [String] = {
- var res = [String]()
- for index in 0x1F600 ... 0x1F64F {
- res.append(String(UnicodeScalar(index) ?? "-"))
- }
- return res
-}()
diff --git a/Example/Example App/StreamDeckLayoutView.swift b/Example/Example App/StreamDeckLayoutView.swift
deleted file mode 100644
index 067ec07e..00000000
--- a/Example/Example App/StreamDeckLayoutView.swift
+++ /dev/null
@@ -1,54 +0,0 @@
-//
-// StreamDeckLayoutView.swift
-// Example App
-//
-// Created by Roman Schlagowsky on 02.02.24.
-//
-
-import StreamDeckKit
-import StreamDeckSimulator
-import SwiftUI
-
-struct StreamDeckLayoutView: View {
- var body: some View {
- StreamDeckLayout {
- StreamDeckKeypadLayout { _ in
- NumberDisplayKey()
- }
- }
- .background {
- LinearGradient(
- gradient: .init(colors: [.teal, .blue]),
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- }
- }
-}
-
-struct NumberDisplayKey: StreamDeckView {
- @Environment(\.streamDeckViewContext) var context
- @State var isPressed: Bool = false
-
- var emoji: String { emojis[context.index] }
-
- var streamDeckBody: some View {
- StreamDeckKeyView { isPressed in
- self.isPressed = isPressed
- } content: {
- ZStack {
- isPressed ? Color.orange : Color.clear
- Text("\(emoji)")
- .font(isPressed ? .largeTitle : .title)
- }
- }
- }
-}
-
-#Preview {
- StreamDeckSimulator.PreviewView(streamDeck: .regular) {
- StreamDeckSession.setUp { _ in
- StreamDeckLayoutView()
- }
- }
-}
diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj
index 3e627273..289aaedb 100644
--- a/Example/Example.xcodeproj/project.pbxproj
+++ b/Example/Example.xcodeproj/project.pbxproj
@@ -7,7 +7,12 @@
objects = {
/* Begin PBXBuildFile section */
- 30303E232B6CDEF500DC3084 /* StreamDeckLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30303E222B6CDEF500DC3084 /* StreamDeckLayoutView.swift */; };
+ 0B063EFA2B84D51E00778376 /* 1_StatelessStreamDeckLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B063EF92B84D51E00778376 /* 1_StatelessStreamDeckLayout.swift */; };
+ 0B063EFC2B84DBF800778376 /* 2_StatefulStreamDeckLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B063EFB2B84DBF800778376 /* 2_StatefulStreamDeckLayout.swift */; };
+ 0B9B65D52B85E4CD001C06F4 /* BaseStreamDeckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B9B65D42B85E4CD001C06F4 /* BaseStreamDeckView.swift */; };
+ 0BD459DE2B86038800964743 /* 3_AnimatedStreamDeckLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BD459DD2B86038800964743 /* 3_AnimatedStreamDeckLayout.swift */; };
+ 0BD459E02B86093300964743 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BD459DF2B86093300964743 /* EnvironmentValues.swift */; };
+ 2153E38F2B850CD0001E37B5 /* ExampleDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2153E38E2B850CD0001E37B5 /* ExampleDataModel.swift */; };
3073016D2B3DB70000C5F5B4 /* StreamDeckKitExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3073016C2B3DB70000C5F5B4 /* StreamDeckKitExampleApp.swift */; };
3073016F2B3DB70000C5F5B4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3073016E2B3DB70000C5F5B4 /* ContentView.swift */; };
307301712B3DB70100C5F5B4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 307301702B3DB70100C5F5B4 /* Assets.xcassets */; };
@@ -19,8 +24,13 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
+ 0B063EF92B84D51E00778376 /* 1_StatelessStreamDeckLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = 1_StatelessStreamDeckLayout.swift; sourceTree = ""; };
+ 0B063EFB2B84DBF800778376 /* 2_StatefulStreamDeckLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = 2_StatefulStreamDeckLayout.swift; sourceTree = ""; };
+ 0B9B65D42B85E4CD001C06F4 /* BaseStreamDeckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStreamDeckView.swift; sourceTree = ""; };
+ 0BD459DD2B86038800964743 /* 3_AnimatedStreamDeckLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = 3_AnimatedStreamDeckLayout.swift; sourceTree = ""; };
+ 0BD459DF2B86093300964743 /* EnvironmentValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; };
+ 2153E38E2B850CD0001E37B5 /* ExampleDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleDataModel.swift; sourceTree = ""; };
21DE1EAA2B46E86600B18417 /* Example-App-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Example-App-Info.plist"; sourceTree = SOURCE_ROOT; };
- 30303E222B6CDEF500DC3084 /* StreamDeckLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDeckLayoutView.swift; sourceTree = ""; };
307301692B3DB70000C5F5B4 /* StreamDeckKit Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "StreamDeckKit Example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
3073016C2B3DB70000C5F5B4 /* StreamDeckKitExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamDeckKitExampleApp.swift; sourceTree = ""; };
3073016E2B3DB70000C5F5B4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
@@ -45,6 +55,18 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
+ 0B9B65D32B85E4C6001C06F4 /* Examples */ = {
+ isa = PBXGroup;
+ children = (
+ 2153E38E2B850CD0001E37B5 /* ExampleDataModel.swift */,
+ 0B9B65D42B85E4CD001C06F4 /* BaseStreamDeckView.swift */,
+ 0B063EF92B84D51E00778376 /* 1_StatelessStreamDeckLayout.swift */,
+ 0B063EFB2B84DBF800778376 /* 2_StatefulStreamDeckLayout.swift */,
+ 0BD459DD2B86038800964743 /* 3_AnimatedStreamDeckLayout.swift */,
+ );
+ path = Examples;
+ sourceTree = "";
+ };
307301602B3DB70000C5F5B4 = {
isa = PBXGroup;
children = (
@@ -69,7 +91,8 @@
307301852B3DBC7600C5F5B4 /* Example App.entitlements */,
3073016C2B3DB70000C5F5B4 /* StreamDeckKitExampleApp.swift */,
3073016E2B3DB70000C5F5B4 /* ContentView.swift */,
- 30303E222B6CDEF500DC3084 /* StreamDeckLayoutView.swift */,
+ 0BD459DF2B86093300964743 /* EnvironmentValues.swift */,
+ 0B9B65D32B85E4C6001C06F4 /* Examples */,
307301702B3DB70100C5F5B4 /* Assets.xcassets */,
307301722B3DB70100C5F5B4 /* Preview Content */,
);
@@ -194,9 +217,14 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 0BD459DE2B86038800964743 /* 3_AnimatedStreamDeckLayout.swift in Sources */,
3073016F2B3DB70000C5F5B4 /* ContentView.swift in Sources */,
3073016D2B3DB70000C5F5B4 /* StreamDeckKitExampleApp.swift in Sources */,
- 30303E232B6CDEF500DC3084 /* StreamDeckLayoutView.swift in Sources */,
+ 2153E38F2B850CD0001E37B5 /* ExampleDataModel.swift in Sources */,
+ 0B063EFC2B84DBF800778376 /* 2_StatefulStreamDeckLayout.swift in Sources */,
+ 0B063EFA2B84D51E00778376 /* 1_StatelessStreamDeckLayout.swift in Sources */,
+ 0B9B65D52B85E4CD001C06F4 /* BaseStreamDeckView.swift in Sources */,
+ 0BD459E02B86093300964743 /* EnvironmentValues.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -348,9 +376,11 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.elgato.samplecode.${DEVELOPMENT_TEAM}.StreamDeckKit-Example";
PRODUCT_NAME = "StreamDeckKit Example";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
+ TARGETED_DEVICE_FAMILY = 2;
};
name = Debug;
};
@@ -380,9 +410,11 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.elgato.samplecode.${DEVELOPMENT_TEAM}.StreamDeckKit-Example";
PRODUCT_NAME = "StreamDeckKit Example";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
+ TARGETED_DEVICE_FAMILY = 2;
};
name = Release;
};
diff --git a/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 00000000..fa0a8339
--- /dev/null
+++ b/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-syntax.git",
+ "state" : {
+ "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d",
+ "version" : "509.1.1"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Package.swift b/Package.swift
index 81e0e9e1..9070828e 100644
--- a/Package.swift
+++ b/Package.swift
@@ -2,10 +2,11 @@
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
+import CompilerPluginSupport
let package = Package(
name: "StreamDeckKit",
- platforms: [.iOS(.v17)],
+ platforms: [.iOS(.v17), .macOS(.v10_15)],
products: [
.library(
name: "StreamDeckKit",
@@ -20,7 +21,11 @@ let package = Package(
.package(
url: "https://github.com/pointfreeco/swift-snapshot-testing",
from: "1.12.0"
- )
+ ),
+ .package(
+ url: "https://github.com/apple/swift-syntax.git",
+ from: "509.0.0"
+ )
],
targets: [
.target(
@@ -30,18 +35,27 @@ let package = Package(
),
.target(
name: "StreamDeckKit",
- dependencies: ["StreamDeckCApi"]
+ dependencies: ["StreamDeckCApi", "StreamDeckMacros"]
),
.target(
name: "StreamDeckCApi",
linkerSettings: [.linkedFramework("IOKit")]
),
+ .macro(
+ name: "StreamDeckMacros",
+ dependencies: [
+ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
+ .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
+ ]
+ ),
.testTarget(
name: "StreamDeckSDKTests",
dependencies: [
"StreamDeckKit",
+ "StreamDeckMacros",
"StreamDeckSimulator",
- .product(name: "SnapshotTesting", package: "swift-snapshot-testing")
+ .product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
+ .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
]
)
]
diff --git a/README.md b/README.md
index 6125f5de..26e7b21f 100644
--- a/README.md
+++ b/README.md
@@ -68,8 +68,8 @@ import StreamDeckKit
// Run session setup to define what should be displayed on every connected device
StreamDeckSession.setUp { device in
print("Rendering \(device.info.productName)")
- return StreamDeckLayout { keypadContext in // Trailing closure (keyAreaView:) expects content for the keypad
- StreamDeckKeypadLayout { keyContext in // Trailing closure (keyView:) expects a factory for keys
+ return StreamDeckLayout { keypadContext in // Trailing closure (keyArea:) expects content for the keypad
+ StreamDeckKeyAreaLayout { keyContext in // Trailing closure (keyView:) expects a factory for keys
StreamDeckKeyView { isDown in
// Handle key down/up events
print(isDown ? "Key is down" : "Key is up")
@@ -87,7 +87,7 @@ This uses predefined layout views to place content on a Stream Deck.
-The closure we passed to `StreamDeckLayout` defines the key area of the device. The closure we passed to `StreamDeckKeypadLayout` is a factory, providing a view for each LED key on the device.
+The closure we passed to `StreamDeckLayout` defines the key area of the device. The closure we passed to `StreamDeckKeyAreaLayout` is a factory, providing a view for each LED key on the device.
We can use the `index` property of the `keyContext` parameter to find out which key is to be rendered.
diff --git a/Sources/StreamDeckKit/Layout/Environment+Ext.swift b/Sources/StreamDeckKit/Layout/Environment+Ext.swift
index ae0eced6..4abd1c0d 100644
--- a/Sources/StreamDeckKit/Layout/Environment+Ext.swift
+++ b/Sources/StreamDeckKit/Layout/Environment+Ext.swift
@@ -7,9 +7,9 @@
import SwiftUI
-public struct StreamDeckViewContextKey: EnvironmentKey {
+struct StreamDeckViewContextKey: EnvironmentKey {
- public static var defaultValue: StreamDeckViewContext = .init(
+ static var defaultValue: StreamDeckViewContext = .init(
device: StreamDeck(
client: StreamDeckClientDummy(),
info: .init(),
diff --git a/Sources/StreamDeckKit/Layout/StreamDeck+Layout.swift b/Sources/StreamDeckKit/Layout/StreamDeck+Layout.swift
deleted file mode 100644
index 0abf10f6..00000000
--- a/Sources/StreamDeckKit/Layout/StreamDeck+Layout.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-//
-// StreamDeck+Layout.swift
-//
-//
-// Created by Alexander Jentz on 29.01.24.
-//
-
-import SwiftUI
-
-public extension StreamDeck {
-
- /// Render the provided content on this device as long as the device remains open.
- /// - Parameter content: The SwiftUI view to render on this device.
- @MainActor
- func render(_ content: Content) {
- let renderer = StreamDeckLayoutRenderer(content: content, device: self)
- onClose(renderer.stop)
- }
-}
diff --git a/Sources/StreamDeckKit/Layout/StreamDeckLayoutRenderer.swift b/Sources/StreamDeckKit/Layout/StreamDeckLayoutRenderer.swift
index 209a5c0b..95f789f9 100644
--- a/Sources/StreamDeckKit/Layout/StreamDeckLayoutRenderer.swift
+++ b/Sources/StreamDeckKit/Layout/StreamDeckLayoutRenderer.swift
@@ -13,24 +13,17 @@ final class StreamDeckLayoutRenderer {
private var cancellable: AnyCancellable?
- private let imageSubject = PassthroughSubject()
-
- public var imagePublisher: AnyPublisher {
- imageSubject.eraseToAnyPublisher()
- }
-
private var dirtyViews = [DirtyMarker]()
- public init() {
- }
+ init() {}
@MainActor
- public init(content: Content, device: StreamDeck) {
+ init(content: Content, device: StreamDeck) {
render(content, on: device)
}
@MainActor
- public func render(_ content: Content, on device: StreamDeck) {
+ func render(_ content: Content, on device: StreamDeck) {
cancellable?.cancel()
dirtyViews = .init([.screen])
@@ -39,9 +32,7 @@ final class StreamDeckLayoutRenderer {
device: device,
dirtyMarker: .screen,
size: device.capabilities.screenSize ?? .zero
- ) { [weak self] in
- self?.updateRequired($0)
- }
+ )
let view = content
.environment(\.streamDeckViewContext, context)
@@ -58,7 +49,7 @@ final class StreamDeckLayoutRenderer {
}
}
- public func stop() {
+ func stop() {
cancellable?.cancel()
}
@@ -72,8 +63,6 @@ final class StreamDeckLayoutRenderer {
log("Layout did change")
let caps = device.capabilities
- imageSubject.send(image)
-
guard !dirtyViews.isEmpty else {
log("no dirty views")
return
diff --git a/Sources/StreamDeckKit/Layout/StreamDeckViewContext.swift b/Sources/StreamDeckKit/Layout/StreamDeckViewContext.swift
index 1e3bf454..e7e32ae2 100644
--- a/Sources/StreamDeckKit/Layout/StreamDeckViewContext.swift
+++ b/Sources/StreamDeckKit/Layout/StreamDeckViewContext.swift
@@ -9,63 +9,39 @@ import Foundation
/// Provides information about the current context (screen, key-area, key, window, dial) in SwiftUI environments.
///
-/// You can access the current context via the environment like this:
-/// ```swift
-/// @Environment(\.streamDeckViewContext) var context
-/// ```
+/// This is used internally by the ``StreamDeckView`` macro and the ``StreamDeckLayout`` system.
public struct StreamDeckViewContext {
- private final class IDGenerator {
- private var _id: UInt64 = 0
- var next: UInt64 {
- if _id == UInt64.max {
- _id = 0
- }
- _id += 1
- return _id
- }
- }
-
- typealias DirtyHandler = (DirtyMarker) -> Void
-
/// The Stream Deck device object.
public let device: StreamDeck
- private(set) var dirtyMarker: DirtyMarker
-
/// The size of the current drawing area.
///
/// Depending on if you access this value in a key area, a window or a key.
- public private(set) var size: CGSize
+ public var size: CGSize
/// The index of an input element.
///
/// The value will be valid, when the current drawing area represents an input element like a key. Otherwise it will be `-1`.
- public private(set) var index: Int
-
- private let onDirty: DirtyHandler?
+ public var index: Int
- private let idGenerator = IDGenerator()
-
- public var nextID: UInt64 { idGenerator.next }
+ private(set) var dirtyMarker: DirtyMarker
init(
device: StreamDeck,
dirtyMarker: DirtyMarker,
size: CGSize,
- index: Int = -1,
- onDirty: StreamDeckViewContext.DirtyHandler? = nil
+ index: Int = -1
) {
self.device = device
self.dirtyMarker = dirtyMarker
self.size = size
self.index = index
- self.onDirty = onDirty
}
@MainActor
- func updateRequired() {
- onDirty?(dirtyMarker)
+ public func updateRequired() {
+ device.renderer.updateRequired(dirtyMarker)
}
func with(dirtyMarker: DirtyMarker, size: CGSize, index: Int) -> Self {
diff --git a/Sources/StreamDeckKit/Models/StreamDeck.swift b/Sources/StreamDeckKit/Models/StreamDeck.swift
index 58a2ebaa..e9a28cdd 100644
--- a/Sources/StreamDeckKit/Models/StreamDeck.swift
+++ b/Sources/StreamDeckKit/Models/StreamDeck.swift
@@ -7,6 +7,7 @@
import Combine
import Foundation
+import SwiftUI
import UIKit
/// An object that represents a physical Stream Deck device.
@@ -27,6 +28,8 @@ public final class StreamDeck {
var operationsTask: Task?
var didSetInputEventHandler = false
+ let renderer = StreamDeckLayoutRenderer()
+
private let inputEventsSubject = PassthroughSubject()
/// A publisher of user input events.
@@ -68,7 +71,10 @@ public final class StreamDeck {
self.client = client
self.info = info
self.capabilities = capabilities
+
startOperationTask()
+
+ onClose(renderer.stop)
}
/// Check if the hardware supports the given feature.
@@ -174,4 +180,11 @@ public final class StreamDeck {
enqueueOperation(.showLogo)
}
+ /// Render the provided content on this device as long as the device remains open.
+ /// - Parameter content: The SwiftUI view to render on this device.
+ @MainActor
+ public func render(_ content: Content) {
+ renderer.render(content, on: self)
+ }
+
}
diff --git a/Sources/StreamDeckKit/Views/StreamDeckKeypadLayout.swift b/Sources/StreamDeckKit/Views/StreamDeckKeypadLayout.swift
index 61b9db0d..f4236c12 100644
--- a/Sources/StreamDeckKit/Views/StreamDeckKeypadLayout.swift
+++ b/Sources/StreamDeckKit/Views/StreamDeckKeypadLayout.swift
@@ -1,5 +1,5 @@
//
-// StreamDeckKeypadLayout.swift
+// StreamDeckKeyAreaLayout.swift
// StreamDeckDriverTest
//
// Created by Alexander Jentz on 27.11.23.
@@ -7,10 +7,10 @@
import SwiftUI
-/// A View that draws the layout of a Stream Deck keypad.
+/// A View that draws the layout of a Stream Deck key area.
///
/// The layout depends on the device from the current ``StreamDeckViewContext`` environment.
-public struct StreamDeckKeypadLayout: View {
+public struct StreamDeckKeyAreaLayout: View {
/// A factory function that provides a view for a key.
///
diff --git a/Sources/StreamDeckKit/Views/StreamDeckLayout.swift b/Sources/StreamDeckKit/Views/StreamDeckLayout.swift
index 79613c8f..16e03fb1 100644
--- a/Sources/StreamDeckKit/Views/StreamDeckLayout.swift
+++ b/Sources/StreamDeckKit/Views/StreamDeckLayout.swift
@@ -13,23 +13,23 @@ import SwiftUI
///
/// Provide this to the `content` parameter of ``StreamDeckSession/setUp(stateHandler:newDeviceHandler:content:)`` or ``StreamDeck/render(_:)``
/// to draw a layout onto a Stream Deck device.
-public struct StreamDeckLayout: View {
+public struct StreamDeckLayout: View {
@Environment(\.streamDeckViewContext) var context
- @ViewBuilder let keyAreaView: @MainActor () -> KeyAreaView
- @ViewBuilder let windowView: @MainActor () -> WindowView
+ @ViewBuilder let keyArea: @MainActor () -> KeyArea
+ @ViewBuilder let windowArea: @MainActor () -> WindowArea
/// Creates a new instance.
/// - Parameters:
- /// - keyAreaView: A view to be rendered on the key area of the layout. Use ``StreamDeckKeypadLayout`` to render separate keys.
- /// - windowView: A view to be rendered in in a possible window area of the layout.
+ /// - keyArea: A view to be rendered on the key area of the layout. Use ``StreamDeckKeyAreaLayout`` to render separate keys.
+ /// - windowArea: A view to be rendered in in a possible window area of the layout.
/// Use ``StreamDeckDialAreaLayout`` to render separate parts of the display. E.g. for each dial on a Stream Deck Plus.
public init(
- @ViewBuilder keyAreaView: @escaping @MainActor () -> KeyAreaView,
- @ViewBuilder windowView: @escaping @MainActor () -> WindowView = { Color.clear }
+ @ViewBuilder keyArea: @escaping @MainActor () -> KeyArea,
+ @ViewBuilder windowArea: @escaping @MainActor () -> WindowArea = { Color.clear }
) {
- self.keyAreaView = keyAreaView
- self.windowView = windowView
+ self.keyArea = keyArea
+ self.windowArea = windowArea
}
public var body: some View {
@@ -43,7 +43,7 @@ public struct StreamDeckLayout: View {
index: -1
)
- keyAreaView()
+ keyArea()
.frame(width: keyAreaSize.width, height: keyAreaSize.height)
.padding(.top, caps.keyAreaTopSpacing)
.padding(.leading, caps.keyAreaLeadingSpacing)
@@ -59,7 +59,7 @@ public struct StreamDeckLayout: View {
index: -1
)
- windowView()
+ windowArea()
.frame(width: windowSize.width, height: windowSize.height, alignment: .bottom)
.environment(\.streamDeckViewContext, windowContext)
}
diff --git a/Sources/StreamDeckKit/Views/StreamDeckView.swift b/Sources/StreamDeckKit/Views/StreamDeckView.swift
index f7ce2b08..f9eb1270 100644
--- a/Sources/StreamDeckKit/Views/StreamDeckView.swift
+++ b/Sources/StreamDeckKit/Views/StreamDeckView.swift
@@ -7,37 +7,49 @@
import SwiftUI
+private var _id: UInt64 = 0
+
+public var _nextID: UInt64 {
+ if _id == UInt64.max {
+ _id = 0
+ }
+ _id += 1
+ return _id
+}
+
/// Protocol for views rendered on StreamDeck.
-/// This automatically tells StreamDeckLayout that the drawing area of this view needs to be updated on the device.
+///
+/// - Note: Use this implicitly by applying the ``StreamDeckView()`` macro.
+public protocol StreamDeckView: View {
+ /// The type of view representing the streamDeckBody of this view.
+ associatedtype StreamDeckBody: View
+ /// The content of the view.
+ @MainActor @ViewBuilder var streamDeckBody: Self.StreamDeckBody { get }
+}
+
+/// Defines and implements conformance of the StreamDeckView protocol.
+///
+/// This macro adds Stream Deck context information and state tracking. Enabling you to to handle different devices and keys.
///
/// ```swift
-/// struct NumberDisplayKey: StreamDeckView {
-/// @Environment(\.streamDeckViewContext) var context
+/// @StreamDeckView
+/// struct NumberDisplayKey {
/// @State var isPressed: Bool = false
///
-/// var body: some View {
+/// var streamDeckBody: some View {
/// StreamDeckKeyView { isPressed in
+/// // Changing state will trigger a re-render on Stream Deck
/// self.isPressed = isPressed
/// } content: {
-/// isPressed ? Color.orange : Color.clear
+/// ZStack {
+/// isPressed ? Color.orange : Color.clear
+/// // Show the current key index
+/// Text("\(viewIndex)")
+/// }
/// }
/// }
/// }
/// ```
-public protocol StreamDeckView: View {
- associatedtype Content: View
-
- var context: StreamDeckViewContext { get }
-
- @MainActor @ViewBuilder var streamDeckBody: Self.Content { get }
-}
-
-extension StreamDeckView {
- @MainActor
- public var body: some View {
- streamDeckBody
- .onChange(of: context.nextID) { _, _ in
- context.updateRequired()
- }
- }
-}
+@attached(extension, conformances: StreamDeckView)
+@attached(member, names: named(_$streamDeckViewContext), named(body), named(streamDeck), named(viewSize), named(viewIndex))
+public macro StreamDeckView() = #externalMacro(module: "StreamDeckMacros", type: "StreamDeckViewMacro")
diff --git a/Sources/StreamDeckMacros/StreamDeckViewMacro.swift b/Sources/StreamDeckMacros/StreamDeckViewMacro.swift
new file mode 100644
index 00000000..44474675
--- /dev/null
+++ b/Sources/StreamDeckMacros/StreamDeckViewMacro.swift
@@ -0,0 +1,153 @@
+import SwiftCompilerPlugin
+import SwiftSyntax
+import SwiftSyntaxBuilder
+import SwiftSyntaxMacros
+
+enum StreamDeckViewDeclError: CustomStringConvertible, Error {
+ case onlyStructs
+ case streamDeckBodyRequired
+ case bodyMustNotBeImplemented
+
+ public var description: String {
+ switch self {
+ case .onlyStructs:
+ "@StreamDeckView can only be used with SwiftUI view structs."
+ case .streamDeckBodyRequired:
+ "@StreamDeckView requires the view to implement streamDeckBody."
+ case .bodyMustNotBeImplemented:
+ "@StreamDeckView view must not implement `body`"
+ }
+ }
+}
+
+struct StreamDeckViewMacro: MemberMacro {
+
+ static let contextAccessor = "_$streamDeckViewContext"
+
+ static func expansion( // swiftlint:disable:this function_body_length
+ of node: SwiftSyntax.AttributeSyntax,
+ providingMembersOf declaration: some SwiftSyntax.DeclGroupSyntax,
+ in context: some SwiftSyntaxMacros.MacroExpansionContext
+ ) throws -> [SwiftSyntax.DeclSyntax] {
+ guard let identified = declaration.as(StructDeclSyntax.self) else {
+ throw StreamDeckViewDeclError.onlyStructs
+ }
+
+ let vars = identified.memberBlock.members
+ .map(\.decl)
+ .compactMap { $0.as(VariableDeclSyntax.self) }
+ .compactMap(\.bindings.first?.pattern)
+ .compactMap { $0.as(IdentifierPatternSyntax.self)?.identifier.text }
+
+ guard !vars.contains(where: { $0 == "body" }) else {
+ throw StreamDeckViewDeclError.bodyMustNotBeImplemented
+ }
+
+ guard vars.contains(where: { $0 == "streamDeckBody" }) else {
+ throw StreamDeckViewDeclError.streamDeckBodyRequired
+ }
+
+ let context: DeclSyntax =
+ """
+ @Environment(\\.streamDeckViewContext) var \(raw: contextAccessor)
+ """
+
+ let streamDeck: DeclSyntax =
+ """
+ /// The Stream Deck device object.
+ var streamDeck: StreamDeck {
+ \(raw: contextAccessor).device
+ }
+ """
+
+ let viewSize: DeclSyntax =
+ """
+ /// The size of the current drawing area.
+ var viewSize: CGSize {
+ \(raw: contextAccessor).size
+ }
+ """
+
+ let viewIndex: DeclSyntax =
+ """
+ /// The index of this input element if this is a key or dial view otherwise -1.
+ var viewIndex: Int {
+ \(raw: contextAccessor).index
+ }
+ """
+
+ let body: DeclSyntax =
+ """
+ @MainActor
+ var body: some View {
+ if #available(iOS 17, *) {
+ return streamDeckBody
+ .onChange(of: StreamDeckKit._nextID) {
+ \(raw: contextAccessor).updateRequired()
+ }
+ } else {
+ return streamDeckBody
+ .onChange(of: StreamDeckKit._nextID) { _ in
+ \(raw: contextAccessor).updateRequired()
+ }
+ }
+ }
+ """
+
+ return [
+ context,
+ streamDeck,
+ viewSize,
+ viewIndex,
+ body
+ ]
+ }
+}
+
+extension StreamDeckViewMacro: ExtensionMacro {
+ static func expansion(
+ of node: AttributeSyntax,
+ attachedTo declaration: some DeclGroupSyntax,
+ providingExtensionsOf type: some TypeSyntaxProtocol,
+ conformingTo protocols: [TypeSyntax],
+ in context: some MacroExpansionContext
+ ) throws -> [ExtensionDeclSyntax] {
+ [try ExtensionDeclSyntax("extension \(type): StreamDeckView {}")]
+ }
+}
+
+extension StreamDeckViewMacro: MemberAttributeMacro {
+ static func expansion(
+ of node: SwiftSyntax.AttributeSyntax,
+ attachedTo declaration: some SwiftSyntax.DeclGroupSyntax,
+ providingAttributesFor member: some SwiftSyntax.DeclSyntaxProtocol,
+ in context: some SwiftSyntaxMacros.MacroExpansionContext
+ ) throws -> [SwiftSyntax.AttributeSyntax] {
+ guard let variableDecl = member.as(VariableDeclSyntax.self),
+ variableDecl.isStreamDeckBody
+ else { return [] }
+
+ return ["@MainActor", "@ViewBuilder"]
+ }
+
+}
+
+extension VariableDeclSyntax {
+ var isStreamDeckBody: Bool {
+ bindings
+ .contains(where: { syntax in
+ syntax
+ .as(PatternBindingSyntax.self)?
+ .pattern
+ .as(IdentifierPatternSyntax.self)?
+ .identifier.text == "streamDeckBody"
+ })
+ }
+}
+
+@main
+struct StreamDeckMacrosPlugin: CompilerPlugin {
+ public let providingMacros: [Macro.Type] = [
+ StreamDeckViewMacro.self
+ ]
+}
diff --git a/Sources/StreamDeckSimulator/StreamDeckSimulator.PreviewView.swift b/Sources/StreamDeckSimulator/StreamDeckSimulator.PreviewView.swift
index 67c0198a..a3a80622 100644
--- a/Sources/StreamDeckSimulator/StreamDeckSimulator.PreviewView.swift
+++ b/Sources/StreamDeckSimulator/StreamDeckSimulator.PreviewView.swift
@@ -5,16 +5,16 @@
//
import Combine
-import SwiftUI
import StreamDeckKit
+import SwiftUI
public extension StreamDeckSimulator {
- struct PreviewView: View {
+ struct PreviewView: View {
private let product: StreamDeckProduct
private let configuration: StreamDeckSimulator.Configuration
- private let context: Any?
private let showOptions: Bool
+ private let streamDeckView: () -> SDView
@State private var showDeviceBezels: Bool
@State private var showKeyAreaBorders: Bool
@@ -25,13 +25,13 @@ public extension StreamDeckSimulator {
showOptions: Bool = true,
showDeviceBezels: Bool = true,
showKeyAreaBorders: Bool = false,
- context: (() -> Any)? = nil
+ streamDeckView: @escaping () -> SDView
) {
configuration = product.createConfiguration(serialNumber: serialNumber)
self.product = product
- self.context = context?()
self.showOptions = showOptions
+ self.streamDeckView = streamDeckView
_showDeviceBezels = .init(initialValue: showDeviceBezels)
_showKeyAreaBorders = .init(initialValue: showKeyAreaBorders)
@@ -54,10 +54,7 @@ public extension StreamDeckSimulator {
}
}
.onAppear {
- StreamDeckSession.instance._appendSimulator(device: configuration.device)
- }
- .onDisappear {
- StreamDeckSession.instance._removeSimulator(device: configuration.device)
+ configuration.device.render(streamDeckView())
}
.environment(\.streamDeckViewContext, ._createDummyForSimulator(configuration.device))
}
diff --git a/Sources/StreamDeckSimulator/Views/StreamDeckSimulatorView.swift b/Sources/StreamDeckSimulator/Views/StreamDeckSimulatorView.swift
index 7ecb9338..ff9d0ea8 100644
--- a/Sources/StreamDeckSimulator/Views/StreamDeckSimulatorView.swift
+++ b/Sources/StreamDeckSimulator/Views/StreamDeckSimulatorView.swift
@@ -153,13 +153,13 @@ private extension StreamDeckSimulatorView {
@ViewBuilder
var touchPad: some View {
StreamDeckLayout {
- StreamDeckKeypadLayout { context in
+ StreamDeckKeyAreaLayout { context in
SimulatorKeyView(image: buttonImages[context.index]) { pressed in
client.emit(.keyPress(index: context.index, pressed: pressed))
}
.equatable()
}
- } windowView: {
+ } windowArea: {
StreamDeckDialAreaLayout { context in
SimulatorTouchView { localLocation in
let x = CGFloat(context.index) * context.size.width + localLocation.x
@@ -196,12 +196,12 @@ private extension StreamDeckSimulatorView {
@ViewBuilder
var borderOverlay: some View {
StreamDeckLayout {
- StreamDeckKeypadLayout { _ in
+ StreamDeckKeyAreaLayout { _ in
StreamDeckKeyView {} content: {
Color.clear.border(.red)
}
}
- } windowView: {
+ } windowArea: {
StreamDeckDialAreaLayout { _ in
SimulatorTouchView { _ in } onFling: { _, _ in }
.background {
diff --git a/Tests/StreamDeckSDKTests/Helper/StreamDeckRobot.swift b/Tests/StreamDeckSDKTests/Helper/StreamDeckRobot.swift
index 318c57c2..bbb44ed6 100644
--- a/Tests/StreamDeckSDKTests/Helper/StreamDeckRobot.swift
+++ b/Tests/StreamDeckSDKTests/Helper/StreamDeckRobot.swift
@@ -14,8 +14,6 @@ import UIKit
import XCTest
final class StreamDeckRobot {
- private let renderer = StreamDeckLayoutRenderer()
-
var device: StreamDeck!
var client: StreamDeckClientMock!
var recorder: StreamDeckClientMock.Recorder!
@@ -50,7 +48,8 @@ final class StreamDeckRobot {
) async throws {
use(product)
- await renderer.render(content, on: device)
+ await device.render(content)
+
try await recorder.$screens.waitFor(file: file, line: line) {
!$0.isEmpty
}
diff --git a/Tests/StreamDeckSDKTests/Helper/TestViews.swift b/Tests/StreamDeckSDKTests/Helper/TestViews.swift
index 3a1d04f5..1d3dbe53 100644
--- a/Tests/StreamDeckSDKTests/Helper/TestViews.swift
+++ b/Tests/StreamDeckSDKTests/Helper/TestViews.swift
@@ -11,12 +11,27 @@ import SwiftUI
enum TestViews {
final class SimpleEventModel: ObservableObject {
+ enum Event: Equatable, CustomStringConvertible { // swiftlint:disable:this nesting
+ case none, press(Bool), rotate(Int), fling(InputEvent.Direction), touch(CGPoint)
+
+ var description: String {
+ switch self {
+ case .none: "none"
+ case let .press(pressed): pressed ? "pressed" : "released"
+ case let .rotate(steps): "steps \(steps)"
+ case let .fling(direction): "fling \(direction.description)"
+ case let .touch(point): "touch(\(point.x),\(point.y))"
+ }
+ }
+
+ }
+
@Published var lastEvent: Event = .none
}
- struct SimpleKey: StreamDeckView {
+ @StreamDeckView
+ struct SimpleKey {
@StateObject var model = SimpleEventModel()
- @Environment(\.streamDeckViewContext) var context
var streamDeckBody: some View {
StreamDeckKeyView { isPressed in
@@ -28,7 +43,7 @@ enum TestViews {
.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack {
- Text("Key \(context.index)")
+ Text("Key \(viewIndex)")
Text("\(model.lastEvent.description)")
}
}
@@ -37,9 +52,9 @@ enum TestViews {
}
}
- struct SimpleDialView: StreamDeckView {
+ @StreamDeckView
+ struct SimpleDialView {
@StateObject var model = SimpleEventModel()
- @Environment(\.streamDeckViewContext) var context
var streamDeckBody: some View {
StreamDeckDialView { steps in
@@ -50,7 +65,7 @@ enum TestViews {
model.lastEvent = .touch(point)
} content: {
VStack {
- Text("Dial \(context.index)")
+ Text("Dial \(viewIndex)")
Text(model.lastEvent.description)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -59,17 +74,16 @@ enum TestViews {
}
}
- struct SimpleLayout: View {
- @Environment(\.streamDeckViewContext) var context
-
- var body: some View {
+ @StreamDeckView
+ struct SimpleLayout {
+ var streamDeckBody: some View {
StreamDeckLayout(
- keyAreaView: {
- StreamDeckKeypadLayout { _ in
+ keyArea: {
+ StreamDeckKeyAreaLayout { _ in
SimpleKey()
}
},
- windowView: {
+ windowArea: {
StreamDeckDialAreaLayout { _ in
SimpleDialView()
}
@@ -79,58 +93,37 @@ enum TestViews {
}
struct TouchAreaTestLayout: View {
- var body: some View {
- StreamDeckLayout(
- keyAreaView: { StreamDeckKeypadLayout { _ in SimpleKey() } },
- windowView: { WindowLayout() }
- )
- }
- }
-}
+ @StreamDeckView
+ struct WindowLayout { // swiftlint:disable:this nesting
+ @StateObject var model = SimpleEventModel()
-// MARK: - Nested types
-
-extension TestViews.SimpleEventModel {
- enum Event: Equatable, CustomStringConvertible {
- case none, press(Bool), rotate(Int), fling(InputEvent.Direction), touch(CGPoint)
-
- var description: String {
- switch self {
- case .none: "none"
- case let .press(pressed): pressed ? "pressed" : "released"
- case let .rotate(steps): "steps \(steps)"
- case let .fling(direction): "fling \(direction.description)"
- case let .touch(point): "touch(\(point.x),\(point.y))"
+ var streamDeckBody: some View {
+ ZStack {
+ StreamDeckDialAreaLayout(
+ rotate: { _, steps in
+ model.lastEvent = .rotate(steps)
+ },
+ press: { _, isPressed in
+ model.lastEvent = .press(isPressed)
+ },
+ touch: { point in
+ model.lastEvent = .touch(point)
+ },
+ fling: { _, _, direction in
+ model.lastEvent = .fling(direction)
+ },
+ dial: { _ in SimpleDialView() }
+ )
+ Text(model.lastEvent.description)
+ }
}
}
- }
-}
-extension TestViews.TouchAreaTestLayout {
- struct WindowLayout: StreamDeckView {
- @StateObject var model = TestViews.SimpleEventModel()
- @Environment(\.streamDeckViewContext) var context
-
- var streamDeckBody: some View {
- ZStack {
- StreamDeckDialAreaLayout(
- rotate: { _, steps in
- model.lastEvent = .rotate(steps)
- },
- press: { _, isPressed in
- model.lastEvent = .press(isPressed)
- },
- touch: { point in
- model.lastEvent = .touch(point)
- },
- fling: { _, _, direction in
- model.lastEvent = .fling(direction)
- },
- dial: { _ in TestViews.SimpleDialView() }
- )
-
- Text(model.lastEvent.description)
- }
+ var body: some View {
+ StreamDeckLayout(
+ keyArea: { StreamDeckKeyAreaLayout { _ in SimpleKey() } },
+ windowArea: { WindowLayout() }
+ )
}
}
}
diff --git a/Tests/StreamDeckSDKTests/StreamDeckViewMacroTests.swift b/Tests/StreamDeckSDKTests/StreamDeckViewMacroTests.swift
new file mode 100644
index 00000000..f6e28682
--- /dev/null
+++ b/Tests/StreamDeckSDKTests/StreamDeckViewMacroTests.swift
@@ -0,0 +1,133 @@
+import SwiftSyntaxMacros
+import SwiftSyntaxMacrosTestSupport
+import XCTest
+
+// Macro implementations build for the host, so the corresponding module is not available when cross-compiling.
+// Cross-compiled tests may still make use of the macro itself in end-to-end tests.
+#if canImport(StreamDeckMacros)
+@testable import StreamDeckMacros
+
+let testMacros: [String: Macro.Type] = [
+ "StreamDeckView": StreamDeckViewMacro.self
+]
+#endif
+
+final class StreamDeckViewMacroTests: XCTestCase {
+
+ func test_macro() throws { // swiftlint:disable:this function_body_length
+ #if canImport(StreamDeckMacros)
+ assertMacroExpansion(
+ #"""
+ @StreamDeckView
+ struct ContentView {
+ var streamDeckBody: some View {
+ Text("Hello World!")
+ }
+ }
+ """#
+ , expandedSource: #"""
+ struct ContentView {
+ @MainActor @ViewBuilder
+ var streamDeckBody: some View {
+ Text("Hello World!")
+ }
+
+ @Environment(\.streamDeckViewContext) var _$streamDeckViewContext
+
+ /// The Stream Deck device object.
+ var streamDeck: StreamDeck {
+ _$streamDeckViewContext.device
+ }
+
+ /// The size of the current drawing area.
+ var viewSize: CGSize {
+ _$streamDeckViewContext.size
+ }
+
+ /// The index of this input element if this is a key or dial view otherwise -1.
+ var viewIndex: Int {
+ _$streamDeckViewContext.index
+ }
+
+ @MainActor
+ var body: some View {
+ if #available (iOS 17, *) {
+ return streamDeckBody
+ .onChange(of: StreamDeckKit._nextID) {
+ _$streamDeckViewContext.updateRequired()
+ }
+ } else {
+ return streamDeckBody
+ .onChange(of: StreamDeckKit._nextID) { _ in
+ _$streamDeckViewContext.updateRequired()
+ }
+ }
+ }
+ }
+
+ extension ContentView: StreamDeckView {
+ }
+ """#,
+ macros: testMacros)
+ #endif
+ }
+
+ func test_macro_with_body_implementation() throws {
+ #if canImport(StreamDeckMacros)
+ assertMacroExpansion(
+ #"""
+ @StreamDeckView
+ struct ContentView: StreamDeckView {
+ var body: some View {
+ TextView("Hello World!")
+ }
+ }
+ """#
+ , expandedSource: #"""
+ struct ContentView: StreamDeckView {
+ var body: some View {
+ TextView("Hello World!")
+ }
+ }
+
+ extension ContentView: StreamDeckView {
+ }
+ """#,
+ diagnostics: [
+ .init(
+ message: StreamDeckViewDeclError.bodyMustNotBeImplemented.description,
+ line: 1,
+ column: 1
+ )
+ ],
+ macros: testMacros)
+ #endif
+ }
+
+ func test_macro_without_streamDeckBody() throws {
+ #if canImport(StreamDeckMacros)
+ assertMacroExpansion(
+ #"""
+ @StreamDeckView
+ struct ContentView {
+ }
+ """#
+ , expandedSource: #"""
+ struct ContentView {
+ }
+
+ extension ContentView: StreamDeckView {
+ }
+ """#,
+ diagnostics: [
+ .init(
+ message: StreamDeckViewDeclError.streamDeckBodyRequired.description,
+ line: 1,
+ column: 1
+ )
+ ],
+ macros: testMacros)
+ #endif
+ }
+
+}