Search code examples
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:

    • When there is no drag happening, the mask can simply be a Rectangle with the full size of the screen.
    • To position the circle over the start of drag, set its .position to the start-of-drag position. For this to work, the mask alignment needs to be .topLeading and the .local coordinate space needs to be used for the drag gesture.
    • To have the mask move with the drag gesture, apply the .mask modifier before the .offset modifier.
    • One way to make the circle shrink as the drag gesture proceeds is to make its size dependent on the duration of the gesture.
    • To find the width and height of the screen, surround the content with a GeometryReader.
    • To make sure the blue background covers the full mask region, add negative padding so that it extends past the boundaries of the screen.
    • If a DragGesture is used to save the drag offset, it automatically resets at the end of the gesture. This avoids having to reset it in .onEnded.
    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