swiftswiftui

Clip view on drag Swift UI


I would like to clip a view (image) into a circle as the view is dragged down to be dismissed (as in the video below). My current approach has some bugs because dragging the view while changing the frame is very buggy. What is the best way to reproduce the clip/transition in the video?

https://drive.google.com/file/d/1CUzGdsbO4Nx4qQgdonVWMzA_Na0VmwCS/view?usp=sharing

import SwiftUI

struct uyjrbertbr: View {
    @State var offset: CGSize = .zero
    @State var cornerRad: CGFloat = 0.0
    @State var widthStory = 0.0
    @State var heightStory = 0.0
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: cornerRad)
                .foregroundStyle(.blue)
                .ignoresSafeArea()
                .frame(width: widthStory == 0.0 ? widthOrHeight(width: true) : widthStory)
                .frame(height: heightStory == 0.0 ? widthOrHeight(width: false) : heightStory)
                .offset(x: offset.width, y: offset.height)
                .gesture(DragGesture()
                    .onChanged({ value in
                        if value.translation.height > 0 {
                            self.offset = value.translation
                            clip()
                        }
                    })
                    .onEnded({ value in
                        withAnimation(.easeInOut(duration: 0.2)) {
                            offset = .zero
                            cornerRad = 0.0
                            widthStory = widthOrHeight(width: true)
                            heightStory = widthOrHeight(width: false)
                        }
                    })
                )
        }
        .onAppear(perform: {
            widthStory = widthOrHeight(width: true)
            heightStory = widthOrHeight(width: false)
        })
    }
    func clip() {
        var ratio = abs(self.offset.height) / 200.0
        ratio = min(1.0, ratio)
        self.cornerRad = ratio * 300.0
        
        let min = 80.0
        let maxWidth = widthOrHeight(width: true)
        let maxHeight = widthOrHeight(width: false)
        
        widthStory = maxWidth - ((maxWidth - min) * ratio)
        heightStory = maxHeight - ((maxHeight - min) * ratio)
    }
}

#Preview {
    uyjrbertbr()
}

func widthOrHeight(width: Bool) -> CGFloat {
    let scenes = UIApplication.shared.connectedScenes
    let windowScene = scenes.first as? UIWindowScene
    let window = windowScene?.windows.first
    
    if width {
        return window?.screen.bounds.width ?? 0
    } else {
        return window?.screen.bounds.height ?? 0
    }
}

Solution

  • The usual way to clip a view is to apply a .clipShape. However, this takes a Shape as argument, so it is a bit difficult to make it conditional and also to control its size.

    Another way to do it is to apply a mask. This can take a ViewBuilder as argument, so we can make it conditional on when a drag is happening and the frame size can be set easily.

    Then:

    struct ContentView: View {
        let loremIpsum = "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."
        @GestureState private var offset: CGSize = .zero
        @State private var startOfDragLocation: CGPoint = .zero
        @State private var startOfDragTime: Date = .now
        @State private var circleSize: CGFloat = .zero
    
        var body: some View {
            GeometryReader { proxy in
                let h = proxy.size.height
                Text(loremIpsum)
                    .font(.title2)
                    .padding()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background {
                        Color.blue
                            .padding(-h)
                    }
                    .mask(alignment: .topLeading) {
                        if offset == .zero {
                            Rectangle().ignoresSafeArea()
                        } else {
                            Circle()
                                .position(startOfDragLocation)
                                .frame(width: circleSize, height: circleSize)
                        }
                    }
                    .offset(offset)
                    .gesture(
                        DragGesture(minimumDistance: 0, coordinateSpace: .local)
                            .onChanged { value in
                                if startOfDragLocation != value.startLocation {
                                    startOfDragLocation = value.startLocation
                                    startOfDragTime = .now
                                }
                                circleSize = max(100, h + (h * startOfDragTime.timeIntervalSinceNow / 0.5))
                            }
                            .updating($offset) { value, state, trans in
                                state = value.translation
                            }
                    )
            }
        }
    }
    

    Animation