Allow DragGesture to start over a Button in SwiftUI? - swiftui

The code below presents a View in the middle of the screen, and allows you to drag that View. However, if you start the drag on the Button, the drag does not work. You need to drag on the area of the View around the Button.
Is there a way to make these two gestures know about each other, so that you can start the drag from anywhere in the View, even over the Button?
I'm imagining that putting a finger down on the Button would start both gestures as possibilities, and then if you would either lift up or start dragging to decide between the two.
struct ContentView: View {
#State private var dragOffset: CGSize = .zero
#State private var dragStartOffset: CGSize? = nil
var body: some View {
ZStack {
Button("Hello") { print("hello") }
.padding(30)
.background(.regularMaterial)
.gesture(dragGesture)
.offset(dragOffset)
}
}
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if dragStartOffset == nil {
dragStartOffset = dragOffset
}
dragOffset = dragStartOffset! + value.translation
}
.onEnded { _ in
dragStartOffset = nil
}
}
}
func +(a: CGSize, b: CGSize) -> CGSize { CGSize(width: a.width + b.width, height: a.height + b.height) }

Instead of .gesture(dragGesture) try .highPriorityGesture(dragGesture).

Related

SwiftUI (Xcode 14.0.1) - Simultaneous Gestures (DragGesture & MagnificationGesture) - drag killed after magnification gesture activated

I am trying to achieve drag and magnification gestures take in place simultaneously. With the code below, I was able to achieve transitioning dragging to magnifying, however, when I release one finger after magnification, the drag gesture is killed even though one finger is still on the rectangle. In the Photos app in iOS, you can magnify and release one finger then you can still drag an image. I have looked at Simultaneous MagnificationGesture and DragGesture with SwiftUI. Has this been fixed since then? If not, is there any way to achieve this by using UIViewRepresentable?
import SwiftUI
struct DragAndMagnificationGestures: View {
#State private var currentAmount = 0.0
#State private var finalAmount = 1.0
#State private var dragGestureOffset: CGSize = .zero
var body: some View {
VStack {
let combined = dragGesture.simultaneously(with: magnificationGesture)
Rectangle()
.frame(width: 200, height: 200)
.scaleEffect(finalAmount + currentAmount)
.offset(dragGestureOffset)
.gesture(combined)
}
}
}
struct MultiGesture_Previews: PreviewProvider {
static var previews: some View {
DragAndMagnificationGestures()
}
}
extension DragAndMagnificationGestures {
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
if finalAmount != 0 {
withAnimation(.spring()) {
dragGestureOffset = value.translation
}
}
}
.onEnded({ value in
withAnimation(.spring()) {
dragGestureOffset = .zero
}
})
}
private var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { amount in
currentAmount = amount - 1
}
.onEnded({ amount in
finalAmount += currentAmount
currentAmount = 0
})
}
}

Drag gesture only if I start dragging on top of the view that I want to be dragged

I want a view to be dragged only if I start dragging on that view. Currently, if I start dragging from anywhere on the screen and then dragged over it, it drags.
import SwiftUI
struct ContentView: View {
#State private var offset = CGSize.zero
var body: some View {
Text("Hello, world!")
.offset(offset)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded { _ in
offset = CGSize.zero
}
)
}
}
Here is a possible solution. Place a view behind the view you want draggable, such as a Color. Place a DragGesture on that to capture the drag if you miss the view you actually want draggable. This has to be a view that will be shown on screen, or this doesn't work, i.e. you can't use Color.clear.
struct ContentView: View {
#State private var offset = CGSize.zero
var body: some View {
ZStack {
// Place a Color behind your view, set to the background color.
// It can't be clear as that doesn't work.
Color.white
// Place a DragGesture there to capture the drag if not on the view you want
.gesture(DragGesture())
Text("Hello, world!")
.offset(offset)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded { _ in
offset = CGSize.zero
}
)
}
}
}

Can I create a ScrollView that is infinite scrolling but has fixed values?

Given a ScrollView like the following
If I have a ScrollView like the one below, can I display 1 to 10 again after the CircleView of 1 to 10?
I want to use the same 1-10 values and display 1, 2, 3....10 after 10. I want to use the same 1...10 values and display 1, 2, 3...10 after 10.
struct ContentView: View {
var body: some View {
VStack {
Divider()
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(0..<10) { index in
CircleView(label: "\(index)")
}
}.padding()
}.frame(height: 100)
Divider()
Spacer()
}
}
}
struct CircleView: View {
#State var label: String
var body: some View {
ZStack {
Circle()
.fill(Color.yellow)
.frame(width: 70, height: 70)
Text(label)
}
}
}
reference:
https://www.simpleswiftguide.com/how-to-create-horizontal-scroll-view-in-swiftui/
This can be done like illustrated in the Advanced ScrollView Techniques video by Apple. Although that video is from before the SwiftUI era, you can easily implement it in SwiftUI. The advantage of this technique (compared to Toto Minai's answer) is that it does not need to allocate extra memory when scrolling up or down (And you will not run out of memory when you scroll too far 😉).
Here is an implementation in SwiftUI.
import SwiftUI
let overlap = CGFloat(100)
struct InfiniteScrollView<Content: View>: UIViewRepresentable {
let content: Content
func makeUIView(context: Context) -> InfiniteScrollViewRenderer {
let contentWidth = CGFloat(100)
let tiledContent = content
.float(above: content) // For an implementation of these modifiers:
.float(below: content) // see https://github.com/Dev1an/SwiftUI-InfiniteScroll
let contentController = UIHostingController(rootView: tiledContent)
let contentView = contentController.view!
contentView.frame.size.height = contentView.intrinsicContentSize.height
contentView.frame.size.width = contentWidth
contentView.frame.origin.y = overlap
let scrollview = InfiniteScrollViewRenderer()
scrollview.addSubview(contentView)
scrollview.contentSize.height = contentView.intrinsicContentSize.height * 2
scrollview.contentSize.width = contentWidth
scrollview.contentOffset.y = overlap
return scrollview
}
func updateUIView(_ uiView: InfiniteScrollViewRenderer, context: Context) {}
}
class InfiniteScrollViewRenderer: UIScrollView {
override func layoutSubviews() {
super.layoutSubviews()
let halfSize = contentSize.height / 2
if contentOffset.y < overlap {
contentOffset.y += halfSize
} else if contentOffset.y > halfSize + overlap {
contentOffset.y -= halfSize
}
}
}
The main idea is to
Tile the static content. This is done using the float() modifiers.
Change the offset of the scrollview to replace the current view with a previous or next tile when you reach a bound. This is done in layoutSubviews of the InfiniteScrollViewRenderer
Drawback
The main drawback of this technique is that up until now (July 2021) Lazy Stacks don't appear to be rendered lazily when they are not inside a SwiftUI List or ScrollView.
You could use LazyHStack to add a new item when the former item appeared:
// Use `class`, since you don't want to make a copy for each new item
class Item {
var value: Int
// Other properties
init(value: Int) {
self.value = value
}
}
struct ItemWrapped: Identifiable {
let id = UUID()
var wrapped: Item
}
struct ContentView: View {
static let itemRaw = (0..<10).map { Item(value: $0) }
#State private var items = [ItemWrapped(wrapped: itemRaw.first!)]
#State private var index = 0
var body: some View {
VStack {
Divider()
// Scroll indicator might be meaningless?
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 10) {
ForEach(items) { item in
CircleView(label: item.wrapped.value.formatted())
.onAppear {
// Index iteration
index = (index + 1) % ContentView.itemRaw.count
items.append(
ItemWrapped(wrapped: ContentView.itemRaw[index]))
}
}
}.padding()
}.frame(height: 100)
Divider()
Spacer()
}
}
}
You can do it in SwiftUI but it doesn’t use a ScrollView. Try this View available here: https://gist.github.com/kevinbhayes/550e4b080d2761aa20d351ff01bab13e

How to handle DragGesture and PencilKit in SwiftUI?

I'm trying to figure out how I can add a drag gesture to a view that contains a PKCanvasView and allow drawing and dragging. I have the following views:
struct DrawingView: View {
#State private var dragState = CGSize.zero
#State private var translation = CGSize.zero
var body: some View {
let longPressGesture = LongPressGesture(minimumDuration: 0.5)
let dragGesture = DragGesture()
.onChanged { value in
self.translation = value.translation
}
.onEnded { value in
self.dragState.width += value.translation.width
self.dragState.height += value.translation.height
self.translation = .zero
}
let longPressDrag = longPressGesture.sequenced(before: dragGesture)
CanvasView()
.offset(x: dragState.width + translation.width, y: dragState.height + translation.height)
.gesture(longPressDrag)
}
}
struct CanvasView: UIViewRepresentable {
#State var canvas = PKCanvasView()
func makeUIView(context: Context) -> PKCanvasView {
canvas.drawingPolicy = .anyInput
return canvas
}
func updateUIView(_ uiView: PKCanvasView, context: Context) {}
}
With the gesture added to CanvasView, I'm unable to draw. Naturally, if I remove the longPressDrag gesture, drawing works as expected. Seems that the DragGesture is conflicting with the PencilKit in some way (is it implemented internally as a DragGesture?). As seen above, I tried to initially get around this by having the drag gesture preceded by a long press, to no avail. Will I need to somehow need to reconcile this with a custom gesture recognizer? Is it possible to disable to have the drag gesture disabled until the long press is activated? Any insight would be appreciated.

GeometryReader inside ScrollView - scroll doesn't work anymore [duplicate]

With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.
But I was wondering if it is also possible to get the current scroll position?
It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.
Thanks!
It was possible to read it and before. Here is a solution based on view preferences.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
}.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
offset = -proxy.frame(in: .named("scroll")).origin.y
}
return Color.clear
})
}.coordinateSpace(name: "scroll")
}
}
I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).
After a bit of "beautification" I ended up with this usage:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
List(0..<100) { i in
Text("Item \(i)")
.onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
print("rect of item \(i): \(String(describing: frame)))")
}
}
.trackListFrame()
}
}
}
which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking
The most popular answer (#Asperi's) has a limitation:
The scroll offset can be used in a function
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
which is convenient for triggering an event based on that offset.
But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a #State.
The problem then is that each time this offset changes, the #State is updated and the body is re-evaluated. This causes a slow display.
We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
}
where content is (CGPoint) -> some View
We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin,
perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2,
scheduler: DispatchQueue.main),
perform: didEndScrolling)
}
where offsetObserver = PassthroughSubject<CGPoint, Never>()
In the end, this gives :
struct _ScrollViewWithOffset<Content: View>: View {
private let axis: Axis.Set
private let content: (CGPoint) -> Content
private let didEndScrolling: (CGPoint) -> Void
private let offsetObserver = PassthroughSubject<CGPoint, Never>()
private let spaceName = "scrollView"
init(axis: Axis.Set = .vertical,
content: #escaping (CGPoint) -> Content,
didEndScrolling: #escaping (CGPoint) -> Void = { _ in }) {
self.axis = axis
self.content = content
self.didEndScrolling = didEndScrolling
}
var body: some View {
ScrollView(axis) {
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coordinateSpace(name: spaceName)
}
}
Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.
struct ScrollViewWithOffset<Content: View>: View {
#State private var height: CGFloat?
#State private var width: CGFloat?
let axis: Axis.Set
let content: (CGPoint) -> Content
let didEndScrolling: (CGPoint) -> Void
var body: some View {
_ScrollViewWithOffset(axis: axis) { offset in
content(offset)
.fixedSize()
.overlay(GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
width = geo.size.width
}
})
} didEndScrolling: {
didEndScrolling($0)
}
.frame(width: axis == .vertical ? width : nil,
height: axis == .horizontal ? height : nil)
}
}
This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :
struct ScrollViewWithOffsetForPreviews: View {
#State private var cpt = 0
let axis: Axis.Set
var body: some View {
NavigationView {
ScrollViewWithOffset(axis: axis) { offset in
VStack {
Color.pink
.frame(width: 100, height: 100)
Text(offset.x.description)
Text(offset.y.description)
Text(cpt.description)
}
} didEndScrolling: { _ in
cpt += 1
}
.background(Color.mint)
.navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
}
}
}