SwiftUI MagnificationGesture NOT WORKING PROPERLY on Mac - macos

I have a problem with the MagnificationGesture in SwiftUI on the mac. I am writing a Mac app and I want to scale a view. When I run the program, it works fine for a couple of times and then the onChanged closure does not get executed anymore. I am afraid this is a bug... (or do I completely missunderstand something?). I actually found a very recent question on reddit, where someone has the exact same issue: https://www.reddit.com/r/SwiftUI/comments/sd43rk/im_having_an_issue_with_the_magnificationgesture/
I could reproduce the problem in a very simple view:
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
.gesture(MagnificationGesture()
.onChanged({ value in
print(value)
}))
}
}
I really hope, there is a solution to this...
Frederik :)

I accidentally found a fix. I'm listening for both drag and magnification. Drag changes the x/y offsets of an image. When magnification stopped responding, dragging the image slightly would make magnification work again. So, at the end of each magnification event, I add a small offset. Seems to work.
Image(nsImage: nsImage)
.resizable()
.frame(width: width, height: height)
.scaleEffect(finalAmount + currentAmount)
.offset(x: offsetX, y:offsetY)
.gesture(
DragGesture()
.onChanged { value in
offsetY = value.translation.height + offsetYBuffer
offsetX = value.translation.width + offsetXBuffer
}
.onEnded { value in
offsetXBuffer = value.translation.width + offsetXBuffer
offsetYBuffer = value.translation.height + offsetYBuffer
}
).gesture(
MagnificationGesture()
.onChanged { value in
currentAmount = value - 1
}
.onEnded { value in
finalAmount += currentAmount
currentAmount = 0
offsetY += 0.1 //this seems to fix it
}
)

this is my slightly adapted code – for me it works fine, also after 30 times (macOS 12.2beta, Xcode 13.2.1)
struct ContentView: View {
#State private var scale: CGFloat = 1
var body: some View {
Text("Hello, world!")
.scaleEffect(scale)
.padding()
.frame(width: 400, height: 400)
.contentShape(Rectangle())
.gesture(MagnificationGesture()
.onChanged({ value in
scale = value
print(value)
}))
}
}

Related

SwiftUI: Animated rotation in different sides

At first look question can seen ordinary but after spend few hour try out different techniques I still failed to achieve expected behavior... I want to control the rotation side of View depending in #State. If view in default position (chevron up) it should rotate down from the right side. If chevron is down it should rotate up from the left side.
And one more thing I discovered: in my work project I use Image instead SF Symbol with the same modification like in code example and by default it always rotates from the right side, but using SF Symbol it's going from left. I will be grateful if you explain why
struct Arrow: View {
#State var showView = false
var body: some View {
VStack {
Image(systemName: "chevron.up.circle")
.resizable()
.frame(width: 50, height: 50)
.rotationEffect(.degrees(showView ? 180 : 360))
.animation(Animation.easeInOut(duration: 0.3), value: showView)
}
.onTapGesture { showView.toggle() }
}
}
To always rotate in the same direction, maintain a rotationAngle that always increases (or always decreases) while animating:
struct ContentView: View {
#State private var showView = false
#State private var rotationAngle: Double = 0
var body: some View {
VStack {
Image(systemName: "chevron.up.circle")
.resizable()
.frame(width: 50, height: 50)
.rotationEffect(Angle(degrees: rotationAngle))
}
.onTapGesture {
showView.toggle()
withAnimation(.easeInOut(duration: 0.3)) {
rotationAngle += 180
}
rotationAngle = showView ? 180 : 0
}
}
}
Note: Use rotationAngle -= 180 to rotate in the other direction.

SwiftUI transition with opacity not showing

I'm still new to SwiftUI. I'm trying to get each change of an image to start out at opacity 0.0 (fully transparent), then increase to opacity 1.0 (fully opaque)
I expected I could achieve this using the .opacity transition. .opacity is described as a "transition from transparent to opaque on insertion", so my assumption is that by stating "withAnimation" in my Button action, I'd trigger the Image to be re-rendered, and the transition would occur beginning from faded to transparent. Instead I see the same instant appear of the new shape & slow morphing to a new size, no apparent change in .opacity. Code and .gif showing current result, below. I've used UIKit & know I'd set alpha to zero, then UIView.animate to alpha 1.0 over a duration of 1.0, but am unsure how to get the same effect in SwiftUI. Thanks!
struct ContentView: View {
#State var imageName = ""
var imageNames = ["applelogo", "peacesign", "heart", "lightbulb"]
#State var currentImage = -1
var body: some View {
VStack {
Spacer()
Image(systemName: imageName)
.resizable()
.scaledToFit()
.padding()
.transition(.opacity)
Spacer()
Button("Press Me") {
currentImage = (currentImage == imageNames.count - 1 ? 0 : currentImage + 1)
withAnimation(.linear(duration: 1.0)) {
imageName = imageNames[currentImage]
}
}
}
}
}
The reason you are not getting the opacity transition is that you are keeping the same view. Even though it is drawing a different image each time, SwiftUI sees Image as the same. the fix is simple: add .id(). For example:
Image(systemName: imageName)
.resizable()
.scaledToFit()
.padding()
.transition(.opacity)
// Add the id here
.id(imageName)
Here is the correct approach for this kind of issue:
We should not forgot how transition works!!! Transition modifier simply transmit a view to nothing or nothing to a view (This should be written with golden ink)! in your code there is no transition happening instead update happing.
struct ContentView: View {
#State var imageName1: String? = nil
#State var imageName2: String? = nil
var imageNames: [String] = ["applelogo", "peacesign", "heart", "lightbulb"]
#State var currentImage = -1
var body: some View {
VStack {
Spacer()
ZStack {
if let unwrappedImageName: String = imageName1 {
Image(systemName: unwrappedImageName)
.resizable()
.transition(AnyTransition.opacity)
.animation(nil, value: unwrappedImageName)
.scaledToFit()
}
if let unwrappedImageName: String = imageName2 {
Image(systemName: unwrappedImageName)
.resizable()
.transition(AnyTransition.opacity)
.animation(nil, value: unwrappedImageName)
.scaledToFit()
}
}
.padding()
.animation(.linear, value: [imageName1, imageName2])
Spacer()
Button("Press Me") {
currentImage = (currentImage == imageNames.count - 1 ? 0 : currentImage + 1)
if imageName1 == nil {
imageName2 = nil; imageName1 = imageNames[currentImage]
}
else {
imageName1 = nil; imageName2 = imageNames[currentImage]
}
}
}
}
}

Drag degradation when SwiftUI macOS [Toolbar -> ToolbarItem -> Button -> Action] contains #State variable reference

2021/10/28 Update:
Fixed when building with:
macOS 12.0.1 (21A559)
Xcode 13.1 (13A1030d)
2021/10/19 Update:
Bug has been submitted to Apple - FB9713387
Another user has tweeted about this.
Could anyone shed some light on why referencing a #State variable inside the following introduces drag degradation/choppiness, or a workaround?
I have tried the following:
Different variable types
Using the variable within the action and not using it
Running same code on iOS - no lag at all.
Xcode Running Xcode Version 13.0 (13A233)
Code
struct ContentView: View {
#State private var iAmAnUnusedBool: Bool = true
#State private var dragOffset = CGSize.zero
#State private var width: CGFloat = 200
var body: some View {
HStack(alignment: .top, spacing: 0) {
TextEditor(text: .constant("Left Panel"))
Rectangle()
.frame(width: 6)
.onHover { $0 ? NSCursor.resizeLeftRight.push() : NSCursor.pop() }
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .named("contentView"))
.onChanged { gesture in
dragOffset = gesture.translation
}
.onEnded { gesture in
let dragOffsetOriginal = dragOffset.width
dragOffset = .zero
width = width - dragOffsetOriginal
}
)
TextEditor(text: .constant("Right panel"))
.frame(width: width - dragOffset.width)
}
.toolbar {
ToolbarItem {
Button("Button") {
iAmAnUnusedBool // THIS INTRODUCES DEGRADATION
}
}
}
.coordinateSpace(name: "contentView")
}
}
No Lag
Given iAmAnUnusedBool is commented out
When I drag the Rectangle() back and forth
Then there is no lag
Lag
Given iAmAnUnusedBool is uncommented
When I drag the Rectangle() back and forth
Then there is lag (appears 1 minute after rapid dragging)

SwiftUI onAppear: Animation duration is ignored

I have found that this trim animation here animate path stroke drawing in SwiftUI works as expected if the MyLines view appears in the ContentView (root view) of the app or when it appears as destination of a navigation link. However, SwiftUI seems to ignore the animation duration if the view appears inside of a custom view - I created an overlay view that transitions in from the right.
I only took that line trimming animation as an example - this bug (if it is one) also seems to occur with other animations, e.g. some view changing its height on appear.
I tried changing the duration. if I double it (e.g. from 2 seconds to 4), the actual animation duration does not seem to change...
struct ContentView: View {
#State var showOverlay: Bool = false
var body: some View {
NavigationView {
VStack {
NavigationLink("My Lines (Nav Link)", destination: MyLines(height: 200, width: 250))
Button(action: {
self.showOverlay.toggle()
}, label: {
Text("My Lines (overlay)")
})
}
}.overlayView(content: {
VStack {
HStack{
Button(action: { self.showOverlay = false}, label: {
Text("Back")
})
Spacer()
}.padding(.top, 40).padding(.horizontal, 15)
MyLines(height: 200, width: 250)
Spacer()
}
}, background: {
Color(.systemBackground)
}, show: $showOverlay, size: nil, transition: AnyTransition.move(edge: .trailing).animation(.easeInOut(duration: 0.3)))
}
}
Here is the code of MyLine again - I deliberately delay the animation by 1 second so that it becomes clear that the problem is not caused by a too long transition of the overlay view in which the Line view exists.
import SwiftUI
struct MyLines: View {
var height: CGFloat
var width: CGFloat
#State private var percentage: CGFloat = .zero
var body: some View {
Path { path in
path.move(to: CGPoint(x: 0, y: height/2))
path.addLine(to: CGPoint(x: width/2, y: height))
path.addLine(to: CGPoint(x: width, y: 0))
}
.trim(from: 0, to: percentage) // << breaks path by parts, animatable
.stroke(Color.black, style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
withAnimation(.easeOut(duration: 2.0)) {
self.percentage = 1.0
}
})
}.padding()
}
}
I have set up a complete project here that should illustrate the problem and also includes the overlay view:
https://github.com/DominikButz/TrimAnimationBug
By the way, this duration problem does not disappear after removing NavigationView and only use the overlay view.
You might wonder, why use the custom overlay instead of the boiler plate navigation? I found that NavigationView causes a lot of problems:
difficult to change the background colour
does not support custom transitions, e.g. with a matched geometry effect (the overlay view I created can appear with any type of transition)
causes weird crashes (e.g. related to matchedGeometryEffect)
prevents the .hideStatusBar modifier to work properly
etc. etc.
Sorry, just found the culprit. I had left an animation(.spring()) modifier on the Overlay View. that seems to confuse SwiftUI somehow. after removing the modifier, it works as expected. Seems it helps writing down the problem to see the solution more easily...

Getting and setting the position of content in a SwiftUI ScrollView

I have a horizontal scroll view where I'd like to set a position programmatically. Here is the body of the view:
let radius = CGFloat(25)
let scrollWidth = CGFloat(700)
let scrollHeight = CGFloat(100)
let spacing = CGFloat(20)
let circleDiameter = CGFloat(50)
...
var body: some View {
GeometryReader { viewGeometry in
ScrollView () {
VStack(spacing: 0) {
Spacer(minLength: spacing)
Circle()
.fill(Color.black.opacity(0.5))
.scaledToFit()
.frame(width: circleDiameter, height: circleDiameter)
ScrollView(.horizontal) {
Text("The quick brown fox jumps over the lazy dog. Finalmente.")
.font(Font.title)
.frame(width: scrollWidth, height: scrollHeight)
.foregroundColor(Color.white)
.background(Color.white.opacity(0.25))
}
.frame(width: viewGeometry.size.width, height: scrollHeight)
.padding([.top, .bottom], spacing)
Circle()
.fill(Color.white.opacity(0.5))
.scaledToFit()
.frame(width: circleDiameter, height: circleDiameter)
Spacer(minLength: spacing)
}
.frame(width: viewGeometry.size.width)
}
.background(Color.orange)
}
.frame(width: 324 / 2, height: spacing * 4 + circleDiameter * 2 + scrollHeight) // testing
.cornerRadius(radius)
.background(Color.black)
}
How do I change this code so that I can get the current position of "The quick brown fox" and restore it at a later time? I'm just trying to do something like we've always done with contentOffset in UIKit.
I can see how a GeometryReader might be useful to get the content's current frame, but there's no equivalent writer. Setting a .position() or .offset() for the scroll view or text hasn't gotten me anywhere either.
Any help would be most appreciated!
I've been playing around with a solution and posted a Gist to what I have working in terms of programmatically setting content offsets https://gist.github.com/jfuellert/67e91df63394d7c9b713419ed8e2beb7
With the regular SwiftUI ScrollView, as far as I can tell, you can get the position with GeometryReader with proxy.frame(in: .global).minY (see your modified example below), but you cannot set the "contentOffset".
Actually if you look at the Debug View Hierarchy you will notice that our content view is embedded in an internal SwiftUI other content view to the scrollview. So you will offset vs this internal view and not the scroll one.
After searching for quite a while, I could not find any way to do it with the SwiftUI ScrollView (I guess will have to wait for Apple on this one). The best I could do (with hacks) is a scrolltobottom.
UPDATE: I previously made a mistake, as it was on the vertical scroll. Now corrected.
class SGScrollViewModel: ObservableObject{
var scrollOffset:CGFloat = 0{
didSet{
print("scrollOffset: \(scrollOffset)")
}
}
}
struct ContentView: View {
public var scrollModel:SGScrollViewModel = SGScrollViewModel()
let radius = CGFloat(25)
let scrollWidth = CGFloat(700)
let scrollHeight = CGFloat(100)
let spacing = CGFloat(20)
let circleDiameter = CGFloat(50)
var body: some View {
var topMarker:CGFloat = 0
let scrollTopMarkerView = GeometryReader { proxy -> Color in
topMarker = proxy.frame(in: .global).minX
return Color.clear
}
let scrollOffsetMarkerView = GeometryReader { proxy -> Color in
self.scrollModel.scrollOffset = proxy.frame(in: .global).minX - topMarker
return Color.clear
}
return GeometryReader { viewGeometry in
ScrollView () {
VStack(spacing: 0) {
Spacer(minLength: self.spacing)
Circle()
.fill(Color.black.opacity(0.5))
.scaledToFit()
.frame(width: self.circleDiameter, height: self.circleDiameter)
scrollTopMarkerView.frame(height:0)
ScrollView(.horizontal) {
Text("The quick brown fox jumps over the lazy dog. Finally.")
.font(Font.title)
.frame(width: self.scrollWidth, height: self.scrollHeight)
.foregroundColor(Color.white)
.background(Color.white.opacity(0.25))
.background(scrollOffsetMarkerView)
}
.frame(width: viewGeometry.size.width, height: self.scrollHeight)
.padding([.top, .bottom], self.spacing)
Circle()
.fill(Color.white.opacity(0.5))
.scaledToFit()
.frame(width: self.circleDiameter, height: self.circleDiameter)
Spacer(minLength: self.spacing)
}
.frame(width: viewGeometry.size.width)
}
.background(Color.orange)
}
.frame(width: 324 / 2, height: spacing * 4 + circleDiameter * 2 + scrollHeight) // testing
.cornerRadius(radius)
.background(Color.black)
}
}
I messed around with several solutions involving a ScrollView with one or more GeometryReaders, but ultimately I found everything easier if I just ignored ScrollView and rolled my own using View.offset(x:y:) and a DragGesture:
This allows the LinearGradient to be panned by either dragging it like a ScrollView, or by updating the binding, in the case via a Slider
struct SliderBinding: View {
#State var position = CGFloat(0.0)
#State var dragBegin: CGFloat?
var body: some View {
VStack {
Text("\(position)")
Slider(value: $position, in: 0...400)
ZStack {
LinearGradient(gradient: Gradient(colors: [.blue, .red]) , startPoint: .leading, endPoint: .trailing)
.frame(width: 800, height: 200)
.offset(x: position - 400 / 2)
}.frame(width:400)
.gesture(DragGesture()
.onChanged { gesture in
if (dragBegin == nil) {
dragBegin = self.position
} else {
position = (dragBegin ?? 0) + gesture.translation.width
}
}
.onEnded { _ in
dragBegin = nil
}
)
}
.frame(width: 400)
}
}
Clamping the drag operation to the size of the scrolled area is omitted for brevity. This code allows horizontal scrolling, use CGPoints instead of CGSize to implement it horizontally and vertically.

Resources