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.

Using KeyFrameTimeline as value for .keyFrameAnimator view modifier
 
 
Q