How to handle DragGesture and PencilKit in SwiftUI? - 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.

Related

Is there an easy way to pinch to zoom and drag any View in SwiftUI?

I have been looking for a short, reusable piece of code that allows to zoom and drag any view in SwiftUI, and also to change the scale independently.
This would be the answer.
The interesting part that I add is that the scale of the zoomed View can be controled from outside via a binding property. So we don't need to depend just on the pinching gesture, but can add a double tap to get the maximum scale, return to the normal scale, or have a slider (for instance) that changes the scale as we please.
I owe the bulk of this code to jtbandes in his answer to this question.
Here you have in a single file the code of the Zoomable and Scrollable view and a Test View to show how it works:
`
import SwiftUI
let maxAllowedScale = 4.0
struct TestZoomableScrollView: View {
#State private var scale: CGFloat = 1.0
var doubleTapGesture: some Gesture {
TapGesture(count: 2).onEnded {
if scale < maxAllowedScale / 2 {
scale = maxAllowedScale
} else {
scale = 1.0
}
}
}
var body: some View {
VStack(alignment: .center) {
Spacer()
ZoomableScrollView(scale: $scale) {
Image("foto_producto")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
}
.frame(width: 300, height: 300)
.border(.black)
.gesture(doubleTapGesture)
Spacer()
Text("Change the scale")
Slider(value: $scale, in: 0.5...maxAllowedScale + 0.5)
.padding(.horizontal)
Spacer()
}
}
}
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
#Binding private var scale: CGFloat
init(scale: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self._scale = scale
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = maxAllowedScale
scrollView.minimumZoomScale = 1
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.bouncesZoom = true
// Create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content), scale: $scale)
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
uiView.zoomScale = scale
assert(context.coordinator.hostingController.view.superview == uiView)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
#Binding var scale: CGFloat
init(hostingController: UIHostingController<Content>, scale: Binding<CGFloat>) {
self.hostingController = hostingController
self._scale = scale
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
self.scale = scale
}
}
}
`
I think it's the shortest, easiest way to get the desired behaviour. Also, it works perfectly, something that I haven't found in other solutions offered here. For example, the zooming out is smooth and usually it can be jerky if you don't use this approach.
The slider hast that range to show how the minimun and maximum values are respected, in a real app the range would be 1...maxAllowedScale.
As for the double tap, the behaviour can be changed very easily depending pm what you prefer.
I attach video to show everything at once:
I hope this helps anyone who's looking for this feature.

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
})
}
}

SwiftUI UIViewRepresentable ScrollView is not zooming

I am developing an iOS app using the SwiftUI. I need to implement the "pinch to zoom" feature in the app so i tried using the SwiftUI's ScrollView but went in to the problems of not able to pinch and drag the content at the same time as discussed in the question here. So i tried using the UIKit's UIScrollView in an UIViewRepresentable as suggested in the same thread. The problem i am facing now is when i pinch zoom the view the following error is displayed in the console and view doesn't zoom.
[Assert] -[UIScrollView _clampedZoomScale:allowRubberbanding:]: Must be called with non-zero scale
My UIViewRepresentable does not occupy the entire screen and is embedded in a VStack that occupies some portion on the screen along with other elements. I am using NavigationView that holds the VStack. I have an ObservableObject as a state on the main view. I update few #Published properties on this ObservableObject from .onAppear() of the main view. When i stop updating these properties, the zoom seems to be working as expected. I am not sure what is causing the issue, can some one please help if you have faced the above error? Thanks in advance.
I could replicate this with a sample code provided below.
TestScrollViewApp.swift
#main
struct TestScrollViewApp: App {
var body: some Scene {
WindowGroup {
TestView(interactor: TestInteractor())
}
}
}
TestView.swift
import Foundation
import SwiftUI
struct TestView: View {
#ObservedObject var interactor : TestInteractor
var body: some View {
ZStack{
NavigationView{
VStack{
Text("Hi there!")
HStack{
Text("HI i am beside map")
NativeScrollView()
}
Text("Hi I am footer!")
}
}.navigationViewStyle(StackNavigationViewStyle())
}.onAppear{
interactor.setUp()
}
}
}
struct NativeScrollView: UIViewRepresentable {
let imageview = UIImageView()
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIScrollViewDelegate {
let parent: NativeScrollView
var zoomableView: UIView?
init(_ parent: NativeScrollView) {
self.parent = parent
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return zoomableView
}
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.isScrollEnabled = true
scrollView.bouncesZoom = true;
imageview.contentMode = .scaleAspectFit
imageview.autoresizingMask = [.flexibleWidth,.flexibleHeight]
//add the image view
imageview.image = UIImage(named: "mapscreen")
scrollView.addSubview(imageview)
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 4.0
return scrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.zoomableView = imageview
}
}
TestInteractor.swift
class TestInteractor:ObservableObject{
#Published var hasFooter = true
func setUp(){
hasFooter = false
}
}

Allow DragGesture to start over a Button in 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).

Resizable UITextView Has sizeThatFits That Gives Wrong Size in UIViewRepresentable

I am using a UITextView in a SwiftUI app in order to get a list of editable, multiline text fields based on this answer: How do I create a multiline TextField in SwiftUI?
I use the component in SwiftUI like this:
#State private var textHeight: CGFloat = 0
...
GrowingField(text: $subtask.text ?? "", height: $textHeight, changed:{
print("Save...")
})
.frame(height: textHeight)
The GrowingField is defined like this:
struct GrowingField: UIViewRepresentable {
#Binding var text: String
#Binding var height: CGFloat
var changed:(() -> Void)?
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isScrollEnabled = false
textView.backgroundColor = .orange //For debugging
//Set the font size and style...
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != self.text{
uiView.text = self.text
}
recalculateHeight(textView: uiView, height: $height)
}
func recalculateHeight(textView: UITextView, height: Binding<CGFloat>) {
let newSize = textView.sizeThatFits(CGSize(width: textView.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
if height.wrappedValue != newSize.height {
DispatchQueue.main.async {
height.wrappedValue = newSize.height
}
}
}
//Coordinator and UITextView delegates...
}
The problem I'm having is that sizeThatFits calculates the correct height at first, then replaces it with an incorrect height. If I print the newSize inside recalculateHeight() it goes like this when my view loads:
(63.0, 34.333333333333336) <!-- Right
(3.0, 143.33333333333334) <!-- Wrong
(3.0, 143.33333333333334) <!-- Wrong
I have no idea where the wrong size is coming from, and I don't know why the right one is replaced. This is how it looks with the height being way too big:
If I make a change to it, the recalculateHeight() method gets called again via textViewDidChange() and it rights itself:
This is really hacky, but if I put a timer in makeUIView(), it fixes itself as well:
//Eww, gross...
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
recalculateHeight(view: textView, height: $height)
}
Any idea how I can determine where the incorrect sizeThatFits value is coming from and how I can fix it?
It took me a long time to arrive at a solution for this. It turns out the UITextView sizing logic is good. It was a parent animation that presents my views that was causing updateUIView to fire again with in-transition UITextView size values.
By setting .animation(.none) on the parent VStack that holds all my text fields, it stopped the propagation of the animation and now it works. 🙂