swiftswiftuigeometryreaderswiftui-asyncimage

How can I implement a "Drag to resize" for an image in SwiftUI?


Consider having a screen like on the wireframe consisting of text, an image, and a sheet. When the sheet is dragged down, the image should grow vertically and go out of bounds.

This already works to a certain extent, but the image only scales vertically but not horizontally.

The code I used is the following:

struct SizePreferenceKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue: Value = 0

    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = nextValue()
    }
}

struct ContentView: View {
    @State private var selectedPresentationDetent: PresentationDetent = .fraction(0.6)
    @State private var sheetPosition: CGFloat = 0

    var body: some View {
        GeometryReader { geometry in
            VStack {
                GeometryReader { geometryImage in
                    VStack(alignment: .center) {
                        Spacer()
                        Text("Hello, world!")
                            .padding()

                        AsyncImage(
                            url: URL(string: "https://images.unsplash.com/photo-1551709645-3f16f608bb80?ixlib"),
                            content: { image in
                                image.resizable()
                                    .frame(height: geometryImage.size.height * 0.2 + sheetPosition)
                                    .aspectRatio(contentMode: .fit)
                            },
                            placeholder: {}
                        )
                        .frame(height: geometryImage.size.height * 0.2 + sheetPosition)
                    }
                }
                .frame(height: geometry.size.height * 0.5 + geometry.safeAreaInsets.top)
            }
            .sheet(isPresented: .constant(true)) {
                GeometryReader { sheetGeometry in
                    Color.blue
                        .ignoresSafeArea()
                        .interactiveDismissDisabled()
                        .presentationBackgroundInteraction(.enabled)
                        .presentationDetents([
                            .fraction(0.6),
                            .fraction(0.3)
                        ], selection: $selectedPresentationDetent)
                        .presentationDragIndicator(.visible)
                        .preference(key: SizePreferenceKey.self, value: sheetGeometry.frame(in: .global).minY)
                }
                .onPreferenceChange(SizePreferenceKey.self) { preferences in
                    self.sheetPosition = preferences
                }
            }
        }
    }
}

Solution

  • Don't take this answer as production-ready, it needs improvements, though, this is my proposition:

    1. Extend CGSize to expose a function to compute the aspect ratio, like this (handle the case of self.height == 0 depending on what the expected behavior is in your app):
    extension CGSize {
        func aspectRatio() -> CGFloat {
            return self.width/self.height
        }
    }
    
    1. Add the following property to your component:
    @State private var initialImageFrame: CGRect = .zero
    
    1. Replace the closure of AsyncImage with the following code:
    let computedHeight: CGFloat = geometryImage.size.height * 0.2 + sheetPosition
    let computedWidth: CGFloat = computedHeight*geometryImage.size.aspectRatio()
                                            
    image
        .resizable()
        .frame(
            width: computedWidth,
            height: computedHeight
        )
        .aspectRatio(contentMode: .fit)
        .onAppear {
            self.initialImageFrame = geometryImage.frame(in: .global)
        }
    

    This way you're manually making sure that the aspect ratio is preserved during drag.

    1. Embed the AsyncImage inside a ZStack with the following modifiers:
    ZStack {
        //The AsyncImage
    }
    .clipShape(Rectangle())
    .frame(maxWidth: geometry.size.width)
    

    Then you should have a close call to what you're looking for.