Search code examples
iosanimationswiftui

LinearGradient offset animation not working in SwiftUI


I'm trying to gradually reveal a block of Text line by line using a LinearGradient by animating its offset(y:) position but it does not seem to work. Here's what I have:

struct GradientView: View {
    @State var animateBlur = false
    @State var isVisible: Bool = false
    let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. "
    
    var body: some View {
        VStack {
            GroupBox {
                Toggle("Visible", isOn: $isVisible.animation())
            }

            if isVisible {
                Text(text)
                    .font(.title2)
                    .bold()
                    .overlay {
                        GeometryReader { proxy in
//                            Color.red // << works
                            LinearGradient(colors: [.clear, .white], startPoint: .top, endPoint: .bottom) // << does not work
                                .frame(height: proxy.size.height)
                                .offset(y: animateBlur ? proxy.size.height : 0)
                                .animation(.easeInOut(duration: 1), value: animateBlur)
                                .onAppear {
                                    animateBlur.toggle()
                                }
                                
                        }
                    }
                    .clipShape(Rectangle())
            }
            Spacer()
        }
        .onChange(of: isVisible, {
            if isVisible == false { animateBlur = false }
        })
        .padding()
    }
}

There are a couple of issues at present.

  1. It seems to work with Color but not with LinearGradient. After the animation runs, I still see the LinearGradient overlaying the bottom half of the text. I'd like for the LinearGradient to animate out of view vertically so as to show the complete text block.
  2. With just [.clear, .white] as the gradient colors, given the height of the text & distribution of the gradient colors in 50-50 proportion, it shows more than just one line. I can update the colors to have more whites i.e. [.clear, .white, .white, .white, .white, .white] such that the clear portion is constricted to the top of the gradient but that is not feasible. I also tried using gradient stops like so LinearGradient(stops: [.init(color: .clear, location: 0.1), .init(color: .white, location: 0.9)] but it did not yield expected results.

Any help is appreciated.


Solution

  • If you want the text to be revealed by having the gradient move away, then this can be implemented by animating the start and end points of the gradient.

    The start and end points are defined using UnitPoint. Very often, static UnitPoint constants can be supplied here, such as .top, which equates to UnitPoint(x: 0.5, y: 0). However, the start and end points for a linear gradient do not have to be bound within the range 0-1. The visible part of the gradient is indeed the range 0-1, but if the start point uses y: 0 and the end point uses, say, y: 2, then the height of the gradient will be stretched from 0-2 and only the top half of the gradient will be visible.

    The version below uses a gradient that is defined using a Color array with four colors, namely 2 x .clear and 2 x .white. This means, the gradient consists of 3 regions of equal size:

    • the first third is completely clear
    • the middle third is a gradient, going from clear to white
    • the last third is solid white.

    On initial show, the start and end points are configured to show the last third of the gradient, this being the fully-opaque white third. In .onAppear, the flag animateBlur is set. This causes the start and end points to be changed to show the top third of the gradient, which is the fully-transparent third. By animating the change, the visible part of the gradient moves through the middle third and this gives the wipe effect.

    Other notes:

    • doing it this way, there is no need to apply a y-offset
    • this also means, there is no need for a GeometryReader, because the gradient is greedy and will automatically fill the overlay
    • the overlay doesn't need to be clipped either
    • I found it was necessary to apply .compositingGroup() after the overlay, otherwise there was a blink on initial show
    • you might want to consider using a .linear animation, instead of .easeInOut.
    struct GradientView: View {
        @State private var isVisible = false
        @State private var animateBlur = false
        let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. "
    
        var body: some View {
            VStack {
                GroupBox {
                    Toggle("Visible", isOn: $isVisible.animation())
                }
                if isVisible {
                    Text(text)
                        .font(.title2)
                        .bold()
                        .overlay {
                            LinearGradient(
                                colors: [.clear, .clear, .white, .white],
                                startPoint: UnitPoint(x: 0.5, y: animateBlur ? 0 : -2),
                                endPoint: UnitPoint(x: 0.5, y: animateBlur ? 3 : 1)
                            )
                            .animation(.easeInOut(duration: 2), value: animateBlur)
                            .onAppear { animateBlur = true }
                            .onDisappear { animateBlur = false }
                        }
                        .compositingGroup()
                }
                Spacer()
            }
            .padding()
        }
    }
    

    Animation