Search code examples
iosswiftanimationswiftuilinear-gradients

Animated Gradient Background in Swift UI


I'm trying to create an animated "flag" style gradient on top of my User Profile View -kind of similar to what Stripe has here: https://stripe.com/gb

The code I have below toggles the appearance of the gradient when I switch in / out of the page containing this view, but it does not animate it when the page is open. How can I adjust this to create the necessary animation.

import SwiftUI

struct UserProfileHeaderView: View {
    let plan: String
    let headerTitle: String
    let greeting: String
    let planDescription: String
    var isTrailingTitle: Bool
    let description: String
    
    @State private var animateGradient = false
    
    var body: some View {
        let startColor = plan == "Classic" ? Color.purple : plan == "Plus" ? Color.red : Color(hex: "#636363")
        let endColor = plan == "Classic" ? Color.blue : plan == "Plus" ? Color.orange : plan == "Ultimate" ? Color(hex: "#010057") : Color(hex: "#636363")
        
        let gradient = LinearGradient(
            gradient: Gradient(colors: [startColor, endColor]),
            startPoint: animateGradient ? .topLeading : .bottomTrailing,
            endPoint: animateGradient ? .bottomTrailing : .topLeading
        )

        VStack(alignment: .leading, spacing: 8) {
            HStack {
                if !isTrailingTitle {
                    TextInfinity(text: headerTitle)
                        .font(.largeTitle)
                        .foregroundColor(.white)
                        .fontWeight(.bold)
                        .padding()
                        .frame(maxWidth: .infinity, alignment: .leading)
                } else {
                    Spacer()
                    TextInfinity(text: headerTitle)
                        .font(.largeTitle)
                        .foregroundColor(.white)
                        .fontWeight(.bold)
                        .padding()
                        .frame(maxWidth: .infinity, alignment: .trailing)
                }
            }
            HStack {
                if !isTrailingTitle {
                    TextInfinity(text: greeting)
                        .font(.title)
                        .fontWeight(.bold)
                        .foregroundColor(.white)
                        .padding(.leading)
                } else {
                    Spacer()
                    TextInfinity(text: greeting)
                        .font(.title)
                        .fontWeight(.bold)
                        .foregroundColor(.white)
                        .padding(.leading)
                        .padding(.trailing)
                }
            }
            HStack {
                if !isTrailingTitle {
                    TextInfinity(text: description)
                        .font(.title3)
                        .foregroundColor(.white)
                        .padding(.leading)
                } else {
                    Spacer()
                    TextInfinity(text: description)
                        .font(.title3)
                        .foregroundColor(.white)
                        .padding(.leading)
                        .padding(.trailing)
                }
            }
            HStack {
                Spacer()
                TextInfinity(text: plan == "none" ? "NOT SUBSCRIBED" : planDescription.uppercased())
                    .font(.headline)
                    .fontWeight(.bold)
                    .foregroundColor(.white)
                    .padding([.trailing, .bottom], 10)
            }
        }
        .padding(.top, 35)
        .frame(maxWidth: .infinity)
        .background(gradient)
        .padding(.horizontal, 0)
        .padding(.vertical, 20)
        .ignoresSafeArea()
        .onAppear {
            withAnimation(Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
                animateGradient.toggle()
            }
        }
    }
}

// Preview Provider
struct UserProfileHeaderView_Previews: PreviewProvider {
    static var previews: some View {
        UserProfileHeaderView(
            plan: "Classic",
            headerTitle: "Your Profile",
            greeting: "Welcome!",
            planDescription: "Classic Plan",
            isTrailingTitle: false,
            description: "\( DeveloperPreview.shared.user.name ?? "")\(( DeveloperPreview.shared.user.name != nil &&  DeveloperPreview.shared.user.name != "") ? " | " : "")\( DeveloperPreview.shared.user.email)"
        )
    }
}

Solution

  • According to the iOS & iPadOS 17 Release Notes:

    Changing from one Gradient value to another will now animate, but only once apps are rebuilt against the new SDK. (107408691)

    However, I found that the key to getting it working is to apply the .onAppear to the gradient itself.

    Trying this on an iPhone 15 simulator with iOS 17.5, your original code works, but the animation is not smooth. I think the reason is because the start and end points drift from one corner to the other and the gradient is drawn between them. Halfway across, the two points meet, which means there is a straight boundary between the two colors and no gradient. After that, the colors flip, the points drift apart and the gradient widens again.

    It works more smoothly to switch colors, instead of switching start and end positions. It's useful to move the colors to computed properties for this:

    private var gradientStartColor: Color {
        plan == "Classic"
            ? Color.purple
            : plan == "Plus" ? Color.red : Color(hex: "#636363")
    }
    
    private var gradientEndColor: Color {
        plan == "Classic"
            ? Color.blue
            : plan == "Plus"
                ? Color.orange
                : plan == "Ultimate" ? Color(hex: "#010057") : Color(hex: "#636363")
    }
    
    .background(
        LinearGradient(
            colors: [
                animateGradient ? gradientStartColor : gradientEndColor,
                animateGradient ? gradientEndColor : gradientStartColor
            ],
            startPoint: .topLeading,
            endPoint: .bottomTrailing
        )
        .onAppear {
            withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
                animateGradient.toggle()
            }
        }
    )
    

    Animation

    Although this animation is quite smooth, it does not give the effect of a moving stripe. I would guess, if you are looking for an animation in the style of a flag ripple, then you are after a moving stripe.

    To get the effect of a moving stripe, a different approach is needed:

    • use start-end positions that move across the shape, but maintaining the same distance apart, instead of crossing over;
    • color stops (instead of an array of colors) would give more control over the width of the gradient stripe;
    • you might also want to make it autoreverses: false, so that the stripe only moves in one direction.
    .background(
        LinearGradient(
            stops: [
                .init(color: gradientEndColor, location: 0),
                .init(color: gradientEndColor, location: 0.25),
                .init(color: gradientStartColor, location: 0.45),
                .init(color: gradientStartColor, location: 0.55),
                .init(color: gradientEndColor, location: 0.75),
                .init(color: gradientEndColor, location: 1)
            ],
            startPoint: UnitPoint(x: animateGradient ? 0.5 : -1, y: animateGradient ? 0.5 : -0.5),
            endPoint: UnitPoint(x: animateGradient ? 2 : 0.5, y: animateGradient ? 1.5 : 0.5)
        )
        .onAppear {
            withAnimation(.easeInOut(duration: 4).repeatForever(autoreverses: false)) {
                animateGradient.toggle()
            }
        }
    )
    

    Animation