SwiftUI - Animating only view positions - ios

I have an app in which I'm trying to animate different properties differently upon change. In the following demonstration app, a spring animation applies to both size and position when the "Flip" button is pressed:
Here is the code:
class Thing: Identifiable {
var id: Int
init(id: Int) {
self.id = id
}
}
struct ContentView: View {
#State var isFlipped: Bool = false
let thing1 = Thing(id: 1)
let thing2 = Thing(id: 2)
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 20) {
ForEach(isFlipped ? [thing2,thing1] : [thing1, thing2]) { thing in
Text("\(thing.id)").font(.system(size: 150, weight: .heavy))
.scaleEffect(isFlipped ? CGFloat(thing.id)*0.4 : 1.0)
.animation(.spring(response: 0.5, dampingFraction: 0.3))
}
}
Button("Flip") { isFlipped.toggle() }
}
}
}
My question is: how can I animate the positions without animating the scale?
If I remove the .scaleEffect() modifier, just the positions are animated.
But if I then insert it after the .animation() modifier, then no animation at all occurs, not even the positions. Which seems very strange to me!
I'm familiar with the "animation stack" concept - that which animations apply to which view properties depends on the order in which modifiers and animations are applied to the view. But I can't make sense of where the positions lie in that stack… or else how to think about what's going on.
Any thoughts?
EDITED: I changed the .scaleEffect() modifier to operate differently on the different Thing objects, to include that aspect of the problem I face; thank you to #Bill for the solution for the case when it doesn't.

How about scaling the HStack instead of Text?
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 20) {
ForEach(isFlipped ? [thing2,thing1] : [thing1, thing2]) { thing in
Text("\(thing.id)").font(.system(size: 150, weight: .heavy))
}
}
.animation(.spring(response: 0.5, dampingFraction: 0.3))
.scaleEffect(isFlipped ? 0.5 : 1.0)
Button("Flip") { isFlipped.toggle() }
}
}

This is only one year and five months late, but hey!
To stop the animation of scaleEffect it might work to follow the .scaleEffect modifier with an animation(nil, value: isFlipped) modifier.
Paul Hudson (Hacking With Swift) discusses multiple animations and the various modifiers here. You asked about the concepts involved and Paul provides a quick overview.
Alternatively, take a look at the code below. It is my iteration of the solution that Paul suggests.
/*
Project Purpose:
Shows how to control animations for a single view,
when multiple animations are possible
This view has a single button, with both the button's
background color and the clipShape under control
of a boolean. Tapping the button toggles the boolean.
The object is to make the change in clipShape
animated, while the change in background color
is instantaneous.
Take Home: if you follow an "animatable" modifier
like `.background(...)` with an `.animation` modifier
with an `animation(nil)' modifier then that will cancel
animation of the background change. In contrast,
`.animation(.default)` allows the previous animatable
modifier to undergo animation.
*/
import SwiftUI
struct ContentView: View {
#State private var isEnabled = false
var body: some View {
Button( "Background/ClipShape" )
{
isEnabled.toggle()
}
.foregroundColor( .white )
.frame( width: 200, height: 200 )
// background (here dependent on isEnabled) is animatable.
.background( isEnabled ? Color.green : Color.red )
// the following suppresses the animation of "background"
.animation( nil, value: isEnabled )
// clipshape (here dependent on isEnabled) is animatable
.clipShape(
RoundedRectangle(
cornerRadius: isEnabled ? 100 : 0 ))
// the following modifier permits animation of clipShape
.animation( .default, value: isEnabled )
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Related

How to remove Steps in Slider SwiftUI

I created a UISlider via SwiftUI, but there are just too many "step marks" along the track, which doesn't have the "look and feel" I wish to achieve. Anyone knows the trick to remove them other than turning the tint color to black?
It seems that the step/tick marks are always there as long as I pass any step values during UISlider initialization.
struct setLoggingView: View {
#State var restfullness: Int
#State var elapsedRestTime: Double
var totalRestTime: Double
var stepValue: Int
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack {
Text("Rested \(Int(elapsedRestTime)) seconds")
Slider(value: $elapsedRestTime,
in: 0...totalRestTime,
step: Double.Stride(stepValue),
label: {
Text("Slider")
}, minimumValueLabel: {
Text("-\(stepValue)")
}, maximumValueLabel: {
Text("+\(stepValue)")
})
.tint(Color.white)
.padding(.bottom)
Divider()
Spacer()
Text("Restfullness")
.frame(minWidth: 0, maxWidth: .infinity)
restfullnessStepper(restfullnessIndex: restfullness)
Button(action: {
print("Update Button Pressed")
}) {
HStack {
Text("Update")
.fontWeight(.medium)
}
}
.cornerRadius(40)
}
.border(Color.yellow)
}
}
}
}
Tried research and asked a few mentors, but there seems to be no way to remove the steppers if you are using the default UISlider in SwiftUI. The only way is to create completely custom Slider, but I think I will just live with the default version.
Until Apple improves SwiftUI, the way to do this is by providing a custom binding INSTEAD OF using the step parameter:
Slider(value: Binding(get: { dailyGoalMinutes },
set: { newValue in
let base: Int = Int(newValue.rounded())
let modulo: Int = base % 10 // Your step amount. Here, 10.
dailyGoalMinutes = Double(base - modulo)
}),
in: 0...1440)
In this example, dailyGoalMinutes is declared on the view like this:
#State private var dailyGoalMinutes: Double = 180.0
The slider allows the user to select between 0 and 1,440 minutes (1 day) in 10-minute increments. While the slider itself won't snap to those increments while dragging, the value of dailyGoalMinutes will be properly constrained to multiples of 10 within the defined range.
(Note: AppKit behaves the same way; when NSSlider does not have any tick marks visible, the slider does not snap to values.)

SwiftUI unexpected animations when toggling animations In .onAppear (using a GeometryReader's size)

I have a strange animation behavior in SwiftUI. I've tried to create a minimal view that demonstrates it below.
I want to animate in three circles with a fade and a scale effect (see column "What I Expect" below). However, the size of the circles depends on the width of the view, so I'm using a GeometryReader to get that.
I want to start the animation in .onAppear(perform:), but at the time that is called, the GeometryReader hasn't set the size property yet. What I end up with is the animation you see in "Unwanted Animation 1". This is due to the frames being animated from .zero to their correct sizes.
However, whenever I try to disable the animations for the frames by adding a .animation(nil, value: size) modifier, I get an extremely strange animation behavior (see "Unwanted Animation 2"). This I don't understand at all. It somehow adds a horizontal translation to the animation which makes it look even worse. Any ideas what's happening here and how to solve this?
Strangely, everything works fine if I use an explicit animation like this:
.onAppear {
withAnimation {
show.toggle()
}
}
But I want to understand what's going on here.
Thanks!
Update:
Would replacing .onAppear(perform:) with the following code be reasonable? This would trigger only once in the lifetime of the view, right when size changes from .zero to the correct value.
.onChange(of: size) { [size] newValue in
guard size == .zero else { return }
show.toggle()
}
What I Expect
Unwanted Animation 1
Unwanted Animation 2
import SwiftUI
struct TestView: View {
#State private var show = false
#State private var size: CGSize = .zero
var body: some View {
VStack {
circle
circle
circle
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.background {
GeometryReader { proxy in
Color.clear.onAppear { size = proxy.size }
}
}
.onAppear { show.toggle() }
}
private var circle: some View {
Circle()
.frame(width: circleSize, height: circleSize)
.animation(nil, value: size) // This make the circles animate in from the side for some reason (see "Strange Animation 2")
.opacity(show ? 1 : 0)
.scaleEffect(show ? 1 : 2)
.animation(.easeInOut(duration: 1), value: show)
}
private var circleSize: Double {
size.width * 0.2 // Everything works fine if this is a constant
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
The real size is known after first layout, but .onAppear is called before, so layout (including frame change, which is animatable) goes under animation.
To solve this we need to delay state change a bit (until first layout/render finished), like
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
show.toggle()
}
}
... and this is why withAnimation also works - it actually delays closure call to next cycle.
Tested with Xcode 13 / iOS 15

How to use DispatchQueue to make a view appear and disappear after some time?

I want to make a view appear, do something, then disappear with DispatchQueue. I want to toggle the showHand to show the hand then toggleHandAnimation to make the hand move left and right. After some time e.g. 5 seconds, I want that hand to disappear.
I have an implementation below which seems to be working on the build but it seems like there would be a better way. I have the code below.
What is the guidance on implementing views where you want to run multiple tasks at different points in time async?
import SwiftUI
struct ContentView: View {
#State var toggleHandAnimation: Bool = false
#State var showHand: Bool = true
var body: some View {
if showHand {
Image(systemName: "hand.draw")
.font(Font.system(size: 100))
.offset(x: toggleHandAnimation ? -40 : 0, y: 0)
.animation(Animation.easeInOut(duration: 0.6).repeatCount(5))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: {
toggleHandAnimation.toggle()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5, execute: {
showHand.toggle()
})
})
}
}
}
}
I think this is what you are looking for:
struct ContentView: View {
#State var toggleHandAnimation: Bool = false
#State var showHand: Bool = true
var body: some View {
ZStack {
Color(uiColor: .systemBackground)
if showHand {
Image(systemName: "hand.draw")
.font(Font.system(size: 100))
.offset(x: toggleHandAnimation ? -40 : 0, y: 0)
.onAppear {
withAnimation(Animation.easeInOut(duration: 0.5).repeatCount(10)) {
toggleHandAnimation.toggle()
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5, execute: {
showHand.toggle()
})
}
}
}
}
}
A few things, you should read up on intrinsic vs. extrinsic animations. You were trying to use an intrinsic one, but for a situation like this, it should be extrinsic(i.e., withAnimation(). Also, .animation(_ animation:) has been deprecated because it does not work well. You should be using .animation(_ animation:, value:).
Also, I aligned the time so that the animation ends and the hand disappears. The nice thing with using the withAnimation() is that you can animate multiple things occur with the same animation.
As you can see, you didn't need to nest theDispatchQueues. You only needed the one to make the view disappear.
Lastly, I put this all in a ZStack and set a color on that, so that there was always a view to return. The reason is, this will crash if there is suddenly no view being returned. That is what would happen at the end of the animation with out the color. Obviously, you can have whatever view you want, that was just an example.

SwiftUI ScrollView with Tap and Drag gesture

I'm trying to implement a ScrollView with elements which can be tapped and dragged. It should work the following way:
The ScrollView should work normally, so swiping up/down should not interfere with the gestures.
Tapping an entry should run some code. It would be great if there would be a "tap indicator", so the user knows that the tap has been registered (What makes this difficult is that the tap indicator should be triggered on touch down, not on touch up, and should be active until the finger gets released).
Long pressing an entry should activate a drag gesture, so items can be moved around.
The code below covers all of those requirements (except the tap indicator). However, I'm not sure why it works, to be specific, why I need to use .highPriorityGesture and for example can't sequence the Tap Gesture and the DragGesture with .sequenced(before: ...) (that will block the scrolling).
Also, I'd like to be notified on a touch down event (not touch up, see 2.). I tried to use LongPressGesture() instead of TapGesture(), but that blocks the ScrollView scrolling as well and doesn't even trigger the DragGesture afterwards.
Does somebody know how this can be achieved? Or is this the limit of SwiftUI? And if so, would it be possible to port UIKit stuff over to achieve this (I already tried that, too, but was unsuccessful, the content of the ScrollView should also be dynamic so porting over the whole ScrollView might be difficult)?
Thanks for helping me out!
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.highPriorityGesture(TapGesture().onEnded({print("tapped!")}))
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#GestureState var dragging = CGSize.zero
var body: some View {
Circle()
.frame(width: 100, height: 100)
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragging, body: {t, state, _ in
state = t.translation
}))
.offset(dragging)
}
}
I tried a few option and I think a combination of sequenced and simultaneously allows two gestures to run the same time. To achieve a onTouchDown I used a DragGesture with minimum distance of 0.
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#State private var offset = CGSize.zero
#State private var isDragging = false
#GestureState var isTapping = false
var body: some View {
// Gets triggered immediately because a drag of 0 distance starts already when touching down.
let tapGesture = DragGesture(minimumDistance: 0)
.updating($isTapping) {_, isTapping, _ in
isTapping = true
}
// minimumDistance here is mainly relevant to change to red before the drag
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation {
offset = .zero
isDragging = false
}
}
let pressGesture = LongPressGesture(minimumDuration: 1.0)
.onEnded { value in
withAnimation {
isDragging = true
}
}
// The dragGesture will wait until the pressGesture has triggered after minimumDuration 1.0 seconds.
let combined = pressGesture.sequenced(before: dragGesture)
// The new combined gesture is set to run together with the tapGesture.
let simultaneously = tapGesture.simultaneously(with: combined)
return Circle()
.overlay(isTapping ? Circle().stroke(Color.red, lineWidth: 5) : nil) //listening to the isTapping state
.frame(width: 100, height: 100)
.foregroundColor(isDragging ? Color.red : Color.black) // listening to the isDragging state.
.offset(offset)
.gesture(simultaneously)
}
}
For anyone interested here is a custom scroll view that will not be blocked by other gestures as mentioned in one of the comments. As this was not possible to be solved with the standard ScrollView.
OpenScrollView for SwiftUI on Github
Credit to
https://stackoverflow.com/a/59897987/12764795
http://developer.apple.com/documentation/swiftui/composing-swiftui-gestures
https://www.hackingwithswift.com/books/ios-swiftui/how-to-use-gestures-in-swiftui

Using SwiftUI to annotate an overlay with existing content shown beneath Path view

I have a SwiftUI app where some content is presented to the user, and then the user will annotate over it with a Path view.
The issue I have is that when I use a clear background for the drawing view, or no background at all, the content beneath it will appear as desired, but the gesture changes for the drawing action do not register.
If however, I assign a non-clear color for the background, then the gesture changes register and the drawing appears.
FWIW, I was able to achieve the desired effect using a UIKit based interface, and would just like to recreate it with SwiftUI now.
The simplified code below is a complete example that demonstrates my predicament. This is just an approximation of the code in use, so it's not exactly production quality.
By changing backgroundColor to clear or by commenting out the .background(self.backgroundColor) line, the BackgroundView will now appear, but onChanged does not get called, so no ink will appear.
If I run as is, then the ink will appear, but I cannot see the BackgroundView.
import SwiftUI
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
ZStack {
DrawingView(backgroundColor: .white,
geometry: geometry)
.zIndex(1)
BackgroundView()
}
}
}
}
struct DrawingView: View {
let backgroundColor: Color
let geometry: GeometryProxy
#State private var points = [CGPoint]()
var body: some View {
Path { path in
guard !points.isEmpty else { return }
for index in 0..<points.count - 1 {
path.move(to: points[index])
path.addLine(to: points[index + 1])
}
}
.stroke(Color.red, lineWidth: 3.0)
.background(self.backgroundColor)
.gesture(DragGesture(minimumDistance: 0.1)
.onChanged({ changeValue in
print("onChanged called")
self.points.append(changeValue.location)
}))
}
}
// Just a simple View to fill the background
struct BackgroundView: View {
var body: some View {
Color.black
.edgesIgnoringSafeArea(.all)
}
}
Instead of background use .contentShape as
.stroke(Color.red, lineWidth: 3.0)
.contentShape(Rectangle()) // << here !!
.gesture(DragGesture(minimumDistance: 0.1)
.onChanged({ changeValue in

Resources