Stretchy Views in SwiftUI

Stretchy Views in SwiftUI

The (kinda) short way...

OK, so this is going to be a really brief one.

SwiftUI can be very limiting sometimes (especially the first version) but can also be quite powerful enabling us to achieve some great results with very little code.

So, this is my version of stretchy headers (and footers), supporting iOS13+ and the positioning of the affected view inside the stretched container in ~50 lines of code.

This is not a robust, one-size-fits-all solution by any means, but it's good enough for the simplest case - a full-screen view.


The star of the show

The implementation will be based on a custom view modifier that can later be applied to any view.

We need three things in order to pin & stretch views:

  1. The "pin" position (top/bottom)
  2. Calculating the height of the view for any given position
  3. Calculating the offset of the view for any given position

The first one is easy, all we need is a var. I gave mine the terrible name isTop and defaulted to true:

struct Stretchy: ViewModifier {
    var isTop = true

Next, let's calculate the height.

All we need to do is calculate how far from the edge we are, and add it to the existing height (as long as it's positive) - (I know, I know, using UIScreen here makes some of you roll your eyes, but I confessed earlier, this is not a silver bullet).

Also, note here that we're using the .global reference (view's frame relative to the screen):

    func heightFor(_ reader: GeometryProxy) -> CGFloat {
        let height = reader.size.height
        let frame  = reader.frame(in: .global)
        let deltaY = isTop ? frame.minY : (UIScreen.main.bounds.size.height - frame.maxY)
        return height + max(0, deltaY)
    }

For the offset, the handling is even simpler.

The logic here is that we'll be passed a geometry proxy and we will compensate for any deviation from the top of the screen (for headers) or the bottom (for footers)

For headers, is the amount of deviation. For footers, we don't need to do something (surprise!) because we have already compensated by raising its height - and since the views will be laid out from top to bottom, the footer will touch the bottom of the screen.

    func offsetFor(_ reader: GeometryProxy) -> CGFloat {
        guard isTop else { return 0 }
        let frame  = reader.frame(in: .global)
        let deltaY = frame.minY
        return min(0, -deltaY)
    }

Finally, let's bring them it all together by applying our calculations to the content:

struct Stretchy: ViewModifier {
    var isTop = true

    func heightFor(_ reader: GeometryProxy) -> CGFloat {
        let height = reader.size.height
        let frame  = reader.frame(in: .global)
        let deltaY = isTop ? frame.minY : (UIScreen.main.bounds.size.height - frame.maxY)
        return height + max(0, deltaY)
    }

    func offsetFor(_ reader: GeometryProxy) -> CGFloat {
        guard isTop else { return 0 }
        let frame  = reader.frame(in: .global)
        let deltaY = frame.minY
        return min(0, -deltaY)
    }

    func body(content: Content) -> some View {
        GeometryReader { reader in
            Color.clear
                .overlay(content.aspectRatio(contentMode: .fill), alignment: .center)
                .clipped()
                .frame(height: heightFor(reader))
                .offset(y: offsetFor(reader))
        }
    }
}

And that is basically it. So let's create a toy project to test this out:

A little extension for making our lives easier:

extension View {
    func stretchy(isTop: Bool = true) -> some View {
        self.modifier(Stretchy(isTop: isTop))
    }
}

And a demo ScrollView with two stretchy guys:

struct ContentView: View {

    let overlay: some View =
    Text("❊ Stretchy")
        .font(.title)
        .fontWeight(.bold)
        .padding()
        .shadow(radius: 4)

    var body: some View {
        ScrollView(showsIndicators: false) {
            VStack {
                Image("header")
                    .resizable()
                    .stretchy()
                    .overlay(overlay, alignment: .bottomLeading)
                    .frame(height: 180)
                ForEach(0..<10) { i in
                    Color.white.opacity(0.1)
                        .frame(height: 80)
                        .cornerRadius(12)
                        .padding(8)
                }
                Image("footer")
                    .resizable()
                    .stretchy(isTop: false)
                    .overlay(overlay, alignment: .topLeading)
                    .frame(height: 180)
            }
        }
        .edgesIgnoringSafeArea(.vertical)
    }

Which gives us something like this:

Simulator Screen Recording - iPhone 13 - 2021-12-12 at 00.45.45.gif


Image Credits:

Did you find this article valuable?

Support Vasilis Akoinoglou by becoming a sponsor. Any amount is appreciated!