1

If you present a Sheet that displays an Image with Text underneath in a ScrollView, when you scroll down then flick back up, notice that it'll scroll beyond the limit and empty space appears above the image until the elastic scroll effect settles. You can then pull down to dismiss the Sheet.

I want to prevent adding space above the Image in the ScrollView. Instead, I want that padding to appear in-between the Image and the first Text. An app that does this is the App Store - when you tap a story in Today, notice the image at the top always remains fixed to the top when scrolled up past the limit, and the elastic bounce effect occurs underneath it.

I suspect GeometryReader could be utilized to get the position in the global CoordinateSpace, but it's not clear to me how to utilize that to obtain the desired behavior.

Screenshot

struct ContentView: View {
    @State private var sheetVisible = false
    
    var body: some View {
        VStack {
            Button(action: {
                sheetVisible = true
            }, label: {
                Text("Present Sheet")
            })
        }
        .sheet(isPresented: $sheetVisible) {
            DetailsView(image: UIImage(named: "test"))
        }
    }
}
struct DetailsView: View {
    var image: UIImage?
    
    var body: some View {
        ScrollView {
            Image(uiImage: image ?? UIImage())
                .resizable()
                .aspectRatio((image?.size.width ?? 1) / (image?.size.height ?? 1), contentMode: .fill)
                .background(Color(.secondarySystemFill))
            
            ForEach(0..<50) { index in
                Text("\(index)")
            }
        }
    }
}
Jordan H
  • 52,571
  • 37
  • 201
  • 351
  • Don't you want just disable bounces as described in https://stackoverflow.com/a/63322713/12299030? – Asperi Aug 27 '20 at 16:18
  • While that would resolve the undesired behavior, it's desired to still support the elastic scroll behavior rather than abruptly stop scrolling at the top – Jordan H Aug 27 '20 at 16:20

2 Answers2

1

This is possible using GeometryReader:

  1. Wrap the Image in GeometryReader so it's possible to get the position in the global coordinate space.
  2. Make sure to add .scaledToFill() to the GeometryReader because otherwise it won't take up any space in a ScrollView. Alternatively you could solve this by giving the GeometryReader a default frame
  3. Use the minY of the Image in the global coordinate space to set the offset when 'dragging up'. This way it's simulated the image is in a fixed position.

There is a problem with this technique. When dragging the sheet down the image will stick to it's position, which looks weird. I haven't found out how to fix that but maybe this answer will help you.

See the example below.

struct DetailsView: View {
    func getOffsetY(basedOn geo: GeometryProxy) -> CGFloat {
        // Find Y position
        let minY = geo.frame(in: .global).minY
        
        let emptySpaceAboveSheet: CGFloat = 40
        
        // Don't offset view when scrolling down
        if minY <= emptySpaceAboveSheet {
            return 0
        }
        
        // Offset the view when it goes above to simulate it standing still
        return -minY + emptySpaceAboveSheet
    }
    
    var body: some View {
        ScrollView {
            GeometryReader { imageGeo in
                Image(systemName: "option")
                    .resizable()
                    .scaledToFill()
                    .background(Color(.secondarySystemFill))
                    .offset(x: 0, y: self.getOffsetY(basedOn: imageGeo))
            }
            // Need this to make sure the geometryreader has a size
            .scaledToFill()
            
            
            ForEach(0..<50) { index in
                Text("\(index)")
            }
        }
    }
}

Result

milo
  • 936
  • 7
  • 18
  • This almost works! Just the image stays fixed while the sheet is dismissing like you mentioned, and the image aspect ratio is lost. – Jordan H Aug 25 '20 at 14:32
1

Ok, here is possible solution for your case. Tested with Xcode 12b5 / iOS 14.

The idea is to have internal container, that scrolls inside, but reading its coordinate in scroll view coordinate space, compensate image position offsetting it relative to container, which continue scrolling, such giving effect that everything else, ie below image, still has bouncing.

demo

struct DemoView: View {
    var image: UIImage?

    @State private var offset = CGFloat.zero

    var body: some View {
        ScrollView {
            VStack { // internal container
                Image(uiImage: image ?? UIImage())
                    .resizable()
                    .aspectRatio((image?.size.width ?? 1) / (image?.size.height ?? 1), contentMode: .fill)
                    .background(Color(.secondarySystemFill))
                    .offset(y: offset < 0 ? offset : 0)      // compansate offset

                ForEach(0..<50) { index in
                    Text("\(index)")
                }
            }
            .background(GeometryReader {
                // read current position of container inside scroll view
                Color.clear.preference(key: ViewOffsetKey.self,
                    value: -$0.frame(in: .named("scroll")).origin.y)
            })
            .onPreferenceChange(ViewOffsetKey.self) {
                self.offset = $0   // needed offset to shift back image
            }
        }
        .coordinateSpace(name: "scroll")
    }
}

struct ViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690