Skip to main content

Basic Animations

As described in handling state changes, 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:

An animation showing a stateful layout on a Stream Deck +
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: {
            // To react to state changes within each view, extract the view, just as you normally would in SwiftUI
            // Example:
            if streamDeck.info.product == .plus {
                StreamDeckDialAreaLayout { _ in
                    MyDialView()
                }
            } else if streamDeck.info.product == .neo {
                MyNeoPanelView()
            }
        }
    }

    @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
                    )
                }
            }
        }
    }

    @StreamDeckView
    struct MyNeoPanelView {
        @State private var offset: Double = 0
        @State private var targetOffset: Double = 0

        @State private var date: Date = .now

        let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

        var streamDeckBody: some View {
            // Use StreamDeckNeoPanelLayout for Stream Deck Neo
            StreamDeckNeoPanelLayout { touched in
                targetOffset -= touched ? 50 : 0
            } rightTouch: { touched in
                targetOffset += touched ? 50 : 0
            } panel: {
                VStack {
                    Text(date.formatted(date: .complete, time: .omitted))
                    Text(date.formatted(date: .omitted, time: .standard)).bold().monospaced()
                }
                .offset(x: offset)
            }
            .background(Color(white: Double(1) / 5 + 0.5))
            .onReceive(timer, perform: { _ in
                date = .now
            })
            .task(id: targetOffset) {
                // Animate the change of the offset by applying different position values over time
                // Calculate three values in between the current offset and the target offset

                func calculateCenter(_ offsetA: Double, _ offsetB: Double) -> Double {
                    return (offsetA + offsetB) / 2
                }
                let currentOffset = offset
                let centerOffset = calculateCenter(currentOffset, targetOffset)
                let firstQuarterOffset = calculateCenter(currentOffset, centerOffset)
                let thirdQuarterOffset = calculateCenter(currentOffset, targetOffset)

                func apply(_ offset: Double) async {
                    guard !Task.isCancelled else { return }
                    self.offset = offset
                    try? await Task.sleep(for: .milliseconds(50))
                }
                for position in [firstQuarterOffset, centerOffset, thirdQuarterOffset, targetOffset] {
                    await apply(position)
                }
            }
        }
    }
}