Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/macro #22

Merged
merged 17 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 123 additions & 2 deletions Documentation/Layout.md
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()
}
}
```
157 changes: 157 additions & 0 deletions Documentation/Layout_Animated.md
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
)
}
}
}
}

}
```
91 changes: 91 additions & 0 deletions Documentation/Layout_Stateful.md
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))
}
}
}

}

```
Loading