-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Remove background from layout and context from layout view builders * Fix layout and update tests * Finally fix layout and update tests * Add StreamDeckView macro * Some macro and render improvements * Fix tests * Remove StreamDeckViewContext from StreamDeckView definition and generate additional view properties * Rename macro package, adjust macro visibility and check if we can import the macro in its tests * Add @mainactor and @ViewBuilder to streamDeckBody in StreamDeckViewMacro * Add code example to macro docs * Remove all StreamDeckView properties except streamDeckBody into the macro and also generate docs * Documentation StreamDeckLayout (#23) * Documentation for Stream Deck Layout * Move StreamDeckSession.setUp into PreviewView of Simulator * Renamings * Add two examples: Stateless and Stateful * Add ExampleDataModel to share app state with stream deck layout * Code structure * Extend EnvironmentValues for ExampleDataModel, and In Simulator.PreviewView render only the view without the using the session * Add an example for an animated StreamDeckLayout * Renaming * Simplify background colors of dial area * Update layout docs * Update some code comments --------- Co-authored-by: Christiane Göhring <[email protected]> Co-authored-by: Roman Schlagowsky <[email protected]> Co-authored-by: Alexander Jentz <[email protected]> --------- Co-authored-by: Alexander Jentz <[email protected]> Co-authored-by: Roman Schlagowsky <[email protected]> Co-authored-by: Christiane Göhring <[email protected]> Co-authored-by: Christiane Göhring <[email protected]>
- Loading branch information
1 parent
0117b85
commit f11c908
Showing
42 changed files
with
1,378 additions
and
337 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,130 @@ | ||
# 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 | ||
``` | ||
|
||
<picture> | ||
<source media="(prefers-color-scheme: dark)" srcset="_images/StreamDeckLayout.dark.svg"> | ||
<source media="(prefers-color-scheme: light)" srcset="_images/StreamDeckLayout.light.svg"> | ||
<img alt="An illustration of how layers are arranged in StreamDeckLayout" src="_images/StreamDeckLayout.light.svg"> | ||
</picture> | ||
|
||
[...] 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. | ||
> [!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: | ||
|
||
<table> | ||
<tr> | ||
<td>Mini</td> | ||
<td><img src="_images/layout_sd_mini.png"><br> | ||
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. | ||
</td> | ||
<td><img src="_images/layout_sd_mini_device.png"></td> | ||
</tr> | ||
<tr> | ||
<td>Classic</td> | ||
<td><img src="_images/layout_sd_classic.png"></td> | ||
<td><img src="_images/layout_sd_classic_device.png"></td> | ||
</tr> | ||
</tr> | ||
<tr> | ||
<td>XL</td> | ||
<td><img src="_images/layout_sd_xl.png"></td> | ||
<td><img src="_images/layout_sd_xl_device.png"></td> | ||
</tr> | ||
<tr> | ||
<td>Plus</td> | ||
<td><img src="_images/layout_sd_plus.png"></td> | ||
<td><img src="_images/layout_sd_plus_device.png"></td> | ||
</tr> | ||
</tr> | ||
</table> | ||
|
||
|
||
### 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() | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: | ||
|
||
<figure> | ||
<img alt="An animation showing a stateful layout on a Stream Deck +" src="_images/layout_animated_sd_plus_device.gif"> | ||
</figure> | ||
|
||
```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 | ||
) | ||
} | ||
} | ||
} | ||
} | ||
|
||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: | ||
|
||
<figure> | ||
<img alt="An animation showing a stateful layout on a Stream Deck +" src="_images/layout_stateful_sd_plus_device.gif"> | ||
</figure> | ||
|
||
|
||
```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)) | ||
} | ||
} | ||
} | ||
|
||
} | ||
|
||
``` |
Oops, something went wrong.