Search code examples
swiftswiftuilayoutapple-watch

SwiftUI VStack and Spacer not behaving according to docs


In a SwiftUI Apple Watch app, we need some text aligned in a vertical scroll view such that:

  • if the content is small, it should be placed at the bottom of the screen.
  • if the content does not fit into the lower half of the screen, it should extend off-screen at the bottom, such that the user has to scroll down to view the rest of the content.

Here's my take so far:

import SwiftUI

struct ContentView: View {

  var body: some View {
    GeometryReader { geometry in
      ZStack {
        ScrollView(.vertical) {
          VStack(alignment: .leading, spacing: 0) {
            Spacer()
              .frame(minHeight: geometry.size.height / 2)
            Text("Title")
              .bold()
              .background(.red.opacity(0.2))
            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.")
              .background(.red.opacity(0.2))
          }
            .frame(minHeight: geometry.size.height - 20)
            .background(.blue.opacity(0.4))
        }
      }
        .padding(10)
        .frame(width: geometry.size.width, height: geometry.size.height)
    }
      .ignoresSafeArea()
  }
}

As you can see, the Spacer and the frame(minHeight: ...) part serve to place the content on the bottom. The ScrollView's size is fixed to the whole screen.

However, the text is not displayed in full. Here's how it looks:

enter image description here

As you can see, the content does not start in the middle of the screen. Apparently the Spacer gets larger than its minHeight. However, the Spacer is documented to only take excess space in a VStack. There is not any excess space in that VStack, so the Spacer should only be as tall as its minHeight.

What am I missing?

And here's how it looks when scrolled to the bottom:

enter image description here

Why is this text clipped?


Solution

  • The most important part to fix this is the layout priority. To prevent the Spacer from infinitely expanding (because ScrollView will give however much space its contents want) we need to define which takes priority. Without defining priority, by default SwiftUI appears to just evenly distribute the layout between the Spacer and all the Texts 50/50.

    We can reduce the priority of the Spacer expanding with the layoutPriority(_:) modifier. A few other minor adjustments were made, such as setting a minimum height for the VStack so the text aligns to the bottom when it is short.

    Full code:

    struct ContentView: View {
        var body: some View {
            GeometryReader { geo in
                ScrollView {
                    VStack(alignment: .leading, spacing: 0) {
                        Spacer()
                            .frame(maxWidth: .infinity, minHeight: geo.size.height / 2)
                            .layoutPriority(-1)
    
                        Text("Title")
                            .bold()
                            .background(.red.opacity(0.2))
    
                        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.")
    //                    Text("Much shorter text.")
                            .background(.red.opacity(0.2))
                    }
                    .frame(minHeight: geo.size.height)
                    .background(.blue.opacity(0.4))
                }
            }
            .padding(10)
            .navigationBarHidden(true)
            .ignoresSafeArea()
        }
    }
    

    Result:

    Long text Short text
    long text short text