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
}
}
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:
Rectangle
with the full size of the screen..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..mask
modifier before the .offset
modifier.GeometryReader
.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
}
)
}
}
}