Using KeyFrameTimeline as value for .keyFrameAnimator view modifier

I have several views I want to animate in concert. I plan to use keyframe animation. I want to keep the .keyFrameAnimator modifier code small; I have a lot of ...Keyframes inside several KeyframeTracks. It seems like I should be able to isolate the body of the keyframes parameter into a func or var. Builders are such a pain, I can't grok the right way to refactor their bodies out.

I've tried to make a standalone @KeyframeTrackContentBuilder<MyValue> function but cannot figure out the right syntax/incantation to stuff it with KeyframeTracks.

My latest attempt is to create a func that returns a KeyframeTimeline, but that's been a deadend too.

let k: KeyframeTimeline<MyValue> = timeline(...)
CartoonCardView(color: .yellow)
    .keyframeAnimator(
        initialValue: k.value(time: 0)
    ) { content, value in
        content
            .rotationEffect(value.angle)
            .scaleEffect(value.scale)
            .offset(value.offset)
    } keyframes: { _ in k }

The error on k in the last line is "No exact matches in call to static method 'buildExpression'" with the sub-error "Candidate requires that 'KeyframeTimeline<MyValue>' conform to 'KeyframeTrackContent' (requirement specified as 'K' : 'KeyframeTrackContent') (SwiftUICore.KeyframesBuilder)"

Answered by DTS Engineer in 796515022

@LogicalLight You could use for/in loops to place the keyframe in a track, for example:

struct Mark: Animatable {
    var offset: CGSize
    var angle: Angle
    init(offset: CGSize, angle: Angle) {
        self.offset = offset
        self.angle = angle
    }
    init(x: CGFloat, y: CGFloat, angle: Angle) {
        self.offset = CGSize(width: x, height: y)
        self.angle = angle
    }
}
KeyframeTrack(\.offset) {
    for(index, value) in ContentViewTest.blackMarks.enumerated() {
        LinearKeyframe(value.offset, duration: 1.0)
    }
}

Here is a more complete example of what I'm trying for:

import SwiftUI


struct Mark {
    var offset: CGSize
    var angle: Angle
    init(offset: CGSize, angle: Angle) {
        self.offset = offset
        self.angle = angle
    }
    init(x: CGFloat, y: CGFloat, angle: Angle) {
        self.offset = CGSize(width: x, height: y)
        self.angle = angle
    }
}

struct ContentView: View {
    static let blackMarks: [Mark] = [
        Mark(x: 0, y: 0, angle: .degrees(0)),
        Mark(x: 50, y: 0, angle: .degrees(-90)),
        Mark(x: 50, y: -100, angle: .degrees(-180)),
        Mark(x: 0, y: -100, angle: .degrees(-270)),
        Mark(x: 0, y: 0, angle: .degrees(-360))
    ]
    static let redMarks: [Mark] = [
        Mark(x: 0, y: 0, angle: .degrees(0)),
        Mark(x: -50, y: 0, angle: .degrees(90)),
        Mark(x: -50, y: 100, angle: .degrees(180)),
        Mark(x: 0, y: 100, angle: .degrees(270)),
        Mark(x: 0, y: 0, angle: .degrees(360))
    ]

    var body: some View {
        ZStack {
            Rectangle().fill(.black).frame(width: 20, height: 30)
                .keyframeAnimator(initialValue: Self.blackMarks[0]) { content, value in
                    content
                        .rotationEffect(value.angle)
                        .offset(value.offset)
                } keyframes: { _ in
                    KeyframeTrack(\.offset) {
                        LinearKeyframe(Self.blackMarks[1].offset, duration: 1.0)
                        LinearKeyframe(Self.blackMarks[2].offset, duration: 1.0)
                        LinearKeyframe(Self.blackMarks[3].offset, duration: 1.0)
                        LinearKeyframe(Self.blackMarks[4].offset, duration: 1.0)
                    }
                    KeyframeTrack(\.angle) {
                        LinearKeyframe(Self.blackMarks[1].angle, duration: 1.0)
                        LinearKeyframe(Self.blackMarks[2].angle, duration: 1.0)
                        LinearKeyframe(Self.blackMarks[3].angle, duration: 1.0)
                        LinearKeyframe(Self.blackMarks[4].angle, duration: 1.0)
                    }
                }
            Rectangle().fill(.red).frame(width: 20, height: 30)
                .keyframeAnimator(initialValue: Self.blackMarks[0]) { content, value in
                    content
                        .rotationEffect(value.angle)
                        .offset(value.offset)
                } keyframes: { _ in
                    /* Now, generate the equivalent tracks and keyframes as a function of redMarks */
                }

        }
    }
}

#Preview {
    ContentView()
}

@LogicalLight You could use for/in loops to place the keyframe in a track, for example:

struct Mark: Animatable {
    var offset: CGSize
    var angle: Angle
    init(offset: CGSize, angle: Angle) {
        self.offset = offset
        self.angle = angle
    }
    init(x: CGFloat, y: CGFloat, angle: Angle) {
        self.offset = CGSize(width: x, height: y)
        self.angle = angle
    }
}
KeyframeTrack(\.offset) {
    for(index, value) in ContentViewTest.blackMarks.enumerated() {
        LinearKeyframe(value.offset, duration: 1.0)
    }
}

That doesn't hide all the KeyframeTrack items as well. It helps a bit, though:

Rectangle().fill(.red).frame(width: 20, height: 30)
.keyframeAnimator(initialValue: Self.blackMarks[0]) { content, value in
    content
        .rotationEffect(value.angle)
        .offset(value.offset)
} keyframes: { _ in
    /* f(redMarks) */
    KeyframeTrack(\.offset) {
        for value in Self.redMarks.dropFirst() {
            LinearKeyframe(value.offset, duration: 1.0)
        }
    }
    KeyframeTrack(\.angle) {
        for value in Self.redMarks.dropFirst() {
            LinearKeyframe(value.angle, duration: 1.0)
        }
    }

That's still a lot of code for the keyframes parameter. The ideal solution would keep the code very tight in the view's body, and look like something this for readability:

Rectangle().fill(.red).frame(width: 20, height: 30)
    .keyframeAnimator(initialValue: Self.blackMarks[0]) { content, value in
    content
        .rotationEffect(value.angle)
        .offset(value.offset)
} keyframes: { _ in tracks(Self.redMarks) }
Accepted Answer

Here is a solution I'm quite happy with. It involves making the custom ViewModifier, which is fine since that pretty much what f(redMarks) is:

struct CartoonMotion: ViewModifier {
    let marks: [Mark]
    
    func body(content: Content) -> some View {
        content
            .keyframeAnimator(initialValue: marks[0]) { content, value in
                content
                    .rotationEffect(value.angle)
                    .offset(value.offset)
            } keyframes: { _ in
                KeyframeTrack(\.offset) {
                    for value in marks.dropFirst() {
                        LinearKeyframe(value.offset, duration: 1.0)
                    }
                }
                KeyframeTrack(\.angle) {
                    for value in marks.dropFirst() {
                        LinearKeyframe(value.angle, duration: 1.0)
                    }
                }
            }
    }
}

extension View {
    func cartoonMotion(_ marks: [Mark]) -> some View {
        modifier(CartoonMotion(marks: marks))
    }
}

In the main cartoon scene each "sprite" looks like this:

Rectangle().fill(.red).frame(width: 20, height: 30)
    .cartoonMotion(Self.redMarks)

This gives. Avery compact scene view that hides the details of converting Marks to KeyframeTrack and xxxKeyframes. Thanks to DTS Engineer for contributing to this solution.