Wave in Background of ScrollView

Welcome back! This week's posts cover an assortment of SwiftUI micro-interactions that I've made for my apps. The benefits these interactions bring can really help make your app feel polished and simple to use. Today's micro-interactions are all based on my custom Wave shape.

If you found this tip helpful, please consider subscribing using this link, and if you aren't reading this on TrailingClosure.com, please come check us out sometime!

SwiftUI Wave Shape

These animations all start with one thing in common and that's my custom SwiftUI Shape struct, Wave. The way this shape works is by drawing a continuous wave from the leading to trailing side of the frame. Wave has two properties which can change how the shape looks:

  • phase - The phase at which the wave starts
  • waveHeight - The height (or really the amplitude) of the wave.

Here's the code for the Wave shape:

SwiftUI Wave Shape
struct Wave: Shape {
    
    var waveHeight: CGFloat
    var phase: Angle
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: 0, y: rect.maxY)) // Bottom Left
        
        for x in stride(from: 0, through: rect.width, by: 1) {
            let relativeX: CGFloat = x / 50 //wavelength
            let sine = CGFloat(sin(relativeX + CGFloat(phase.radians)))
            let y = waveHeight * sine //+ rect.midY
            path.addLine(to: CGPoint(x: x, y: y))
        }
        
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) // Top Right
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) // Bottom Right
        
        return path
    }
}

Our First Example!

Yes our first example! The Wave shape is all we need to start creating some amazing interactions for our apps. Check out this one I made using two Wave shapes placed inside a ScrollView. Notice how when the user scrolls, the phase of the waves are changed, and thus they appear to move across the screen.

var body: some View {
    ScrollView(.vertical, showsIndicators: false) {
        VStack(spacing: 0) {

            // Other Views...

            GeometryReader { geo in
                ZStack {
                    Wave(waveHeight: 30, phase: Angle(degrees: (Double(geo.frame(in: .global).minY) + 45) * -1 * 0.7))
                        .foregroundColor(.orange)
                        .opacity(0.5)
                    Wave(waveHeight: 30, phase: Angle(degrees: Double(geo.frame(in: .global).minY) * 0.7))
                        .foregroundColor(.red)
                }
            }.frame(height: 70, alignment: .center)

            // Other Views...
        }
    }
}

Using the Wave as a Mask

Another way to use the Wave shape is to create a custom AnimatableModifier that applies the shape as a mask to your view. The one noticeable change you'll see below is that the mask grows or shrinks in size according to the pct property. Take a look at the code below before we get to our second example!

struct WaveMaskModifier: AnimatableModifier {
    
    var pct: CGFloat
    
    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        content
            .mask(
                GeometryReader { geo in
                    VStack {
                        Spacer()
                        ZStack {
                            Wave(waveHeight: 30, phase: Angle(degrees: (Double(pct) * 720 * -1) + 45))
                                .opacity(0.5)
                                .scaleEffect(x: 1.0, y: 1.2, anchor: .center)
                                .offset(x: 0, y: 30)
                            Wave(waveHeight: 30, phase: Angle(degrees: Double(pct) * 720))
                                .scaleEffect(x: 1.0, y: 1.2, anchor: .center)
                                .offset(x: 0, y: 30)
                        }
                        .frame(height: geo.size.height * pct, alignment: .bottom)
                    }
                    
                }
            )
    }
}

Our Second Example!

Yes, the second example! We're moving along quickly today. Below you'll see the animating mask as applied to an array of Image views. When the user taps the screen, the WaveMaskModifer is applied as a custom AnyTransition to animate the change in image displayed. This kind of transition can be useful for various photo/video-sharing apps which showcase those types of content.

I'll provide the code for the transition below, in addition to the code for the example.

WaveMaskModifier as a Transition

Here is the code for the custom transition.

extension AnyTransition {
    static let waveMask = AnyTransition.asymmetric(insertion:
        AnyTransition.modifier(active: WaveMaskModifier(pct: 0), identity: WaveMaskModifier(pct: 1))
        , removal:
            .scale(scale: 1.1)
    )
}

Code for Example 2

Below is the code for the example above.

struct ContentView: View {
    
    @State var index: Int = 0
    
    var images: [Image] = [
        Image("stock_1"),
        Image("stock_2"),
        Image("stock_3"),
        Image("stock_4"),
    ]
    
    var body: some View {
        ZStack {
            ForEach(images.indices) { i in
                if i == index {
                    images[index]
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .transition(.waveMask)
                }
            }
            
        }.onTapGesture {
            withAnimation(.easeOut(duration: 3)) {
                index = (index + 1) % images.count
            }
            
        }.edgesIgnoringSafeArea(.all)
    }
}

Flowing Wave Background

Finally, we're going to use a set of Wave shapes as a background for one of our views. The twist is that the wave will be animating in the background seamlessly to give the view some life while we wait for the user to interact with the screen. See below for the video example!

Our Third Example!

By updating the phase property of the Wave shapes, we get this flowing background that presents itself nicely while waiting for user interaction.

Like this tutorial?

Show us what you've made!

Send us pics! Drop us a link! Anything! Find us on Twitter @TrailingClosure, on Instagram, or email us at [email protected].