Is there an opposite to .onAppear modifier in SwfitUI? - swiftui

I created a custom horizontal scroll, which uses animation to scroll.
When the view is presented, it uses animation to present the views.
So when I invoke the view and the onAppear is initiated, it changes the offset with animation, But this behavior is unwanted. I don't want to animate my view when they are created.
I tried to create #State variable
#State private var myBool = false
and create a condition:
.animation(self.myBool ? Animation.spring : .none)
and then inside .onAppear
.onAppear(
...
some code that changing state variables
...
self.myBool = true
)
but it didn't work, it still uses the animation even when myBool is false
How can I allow animation only AFTER the body is created?
I mean, is there a modifier .onFinish?
This is an example of the code:
struct scroll: View {
#State var offset: 0
var body: some View {
GeometryReader { geo in
HStack {
ForEach(0..<5) { i in
Rectangle()
}
}
.gesture(DragGesture()
.onChange ({ (value) in
self.offset = value.translation.width
}
.onEnded ({ _ in
self.offset = 50
}
)
.offset(x: self.offset)
.animation(.spring)
.onAppear (
self.offset = geo.size.width
)
}
}
}

As I see now you just need to set initial value directly
struct scroll: View {
var body: some View {
GeometryReader { geo in
innerScroll(offset: geo.size.width) // << here !!
}
}
struct innerScroll: View { // inner subview
#State var offset: CGFloat
var body: some View {
HStack {
ForEach(0..<5) { i in
Rectangle()
}
}
.gesture(DragGesture()
.onChanged { (value) in
self.offset = value.translation.width
}
.onEnded { _ in
self.offset = 50
}
)
.offset(x: self.offset)
.animation(.spring())
}
}
}

Maybe I am misunderstanding your question, but if you don't want any animation onAppear, why don't you initialize your offset with 150?
Instead you start with 0 and set it to 150 onAppear which seems to be the reason for the animation because the variable changes onAppear.

Related

SwiftUI gesture state is reset between gestures

I have the following code for a simple square to which I attach a MagnificationGesture to update its state with a pinch to zoom gesture.
import SwiftUI
struct ContentView2: View {
var scale: CGFloat = 1
var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
scale = value
}
}
var body: some View {
VStack {
Text("\(scale)")
Spacer()
Rectangle()
.fill(Color.red)
.scaleEffect(self.scale)
.gesture(
magnificationGesture
)
Spacer()
}
}
}
struct ContentView2_Previews: PreviewProvider {
static var previews: some View {
ContentView2()
}
}
However this simple view behaves weird. When I perform the gesture, the scale #State property is succesfully modified. However when I then do another gesture with my hands, the scale property is reset to its initial state, instead of starting from its current value.
Here is a video of what happens. For example, when the red view is very small, performing the gesture, I would expect that it stays small, instead of completely resetting.
How can I get the desired behaviour - that is - the scale property is not reset
I was able to get it working by adding a bit to the code. Check it out and let me know if this works for your use case:
import SwiftUI
struct ContentView2: View {
var magGesture = MagnificationGesture()
#State var magScale: CGFloat = 1
#State var progressingScale: CGFloat = 1
var magnificationGesture: some Gesture {
magGesture
.onChanged { progressingScale = $0 }
.onEnded {
magScale *= $0
progressingScale = 1
}
}
var body: some View {
VStack {
Text("\(magScale)")
Spacer()
Rectangle()
.fill(Color.red)
.scaleEffect(self.magScale * progressingScale)
.gesture(
magnificationGesture
)
Spacer()
}
}
}
struct ContentView2_Previews: PreviewProvider {
static var previews: some View {
ContentView2()
}
}
I solved it by adding another scale and only updating one of them at the end to keep track of the scale
Code
import SwiftUI
struct ContentView2: View {
#State var previousScale: CGFloat = 1
#State var newScale: CGFloat = 1
var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
newScale = previousScale * value
}
.onEnded { value in
previousScale *= value
}
}
var body: some View {
VStack {
Text("\(newScale)")
Spacer()
Rectangle()
.fill(Color.red)
.scaleEffect(newScale)
.gesture(
magnificationGesture
)
Spacer()
}
}
}
struct ContentView2_Previews: PreviewProvider {
static var previews: some View {
ContentView2()
}
}

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

How to stop SwiftUI DragGesture from animating subviews

I'm building a custom modal and when I drag the modal, any subviews that have animation's attached, they animate while I'm dragging. How do I stop this from happening?
I thought about passing down an #EnvironmentObject with a isDragging flag, but it's not very scalable (and doesn't work well with custom ButtonStyles)
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
.showModal(isShowing: .constant(true))
}
}
extension View {
func showModal(isShowing: Binding<Bool>) -> some View {
ViewOverlay(isShowing: isShowing, presenting: { self })
}
}
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
if isShowing {
Container()
.background(Color.red)
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
bottomState = value.translation.height
}
.onEnded { _ in
if bottomState > 50 {
withAnimation {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
}
}
}
}
struct Container: View {
var body: some View {
// I want this to not animate when dragging the modal
Text("CONTAINER")
.frame(maxWidth: .infinity, maxHeight: 200)
.animation(.spring())
}
}
UPDATE:
extension View {
func animationsDisabled(_ disabled: Bool) -> some View {
transaction { (tx: inout Transaction) in
tx.animation = tx.animation
tx.disablesAnimations = disabled
}
}
}
Container()
.animationsDisabled(isDragging || bottomState > 0)
In real life the Container contains a button with an animation on its pressed state
struct MyButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1)
.animation(.spring())
}
}
Added the animationsDisabled function to the child view which does in fact stop the children moving during the drag.
What it doesn't do is stop the animation when the being initially slide in or dismissed.
Is there a way to know when a view is essentially not moving / transitioning?
Theoretically SwiftUI should not translate animation in this case, however I'm not sure if this is a bug - I would not use animation in Container in that generic way. The more I use animations the more tend to join them directly to specific values.
Anyway... here is possible workaround - break animation visibility by injecting different hosting controller in a middle.
Tested with Xcode 12 / iOS 14
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
Color.clear
if isShowing {
HelperView {
Container()
.background(Color.red)
}
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
bottomState = value.translation.height
}
.onEnded { _ in
if bottomState > 50 {
withAnimation {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
Color.clear
}
}
}
}
struct HelperView<Content: View>: UIViewRepresentable {
let content: () -> Content
func makeUIView(context: Context) -> UIView {
let controller = UIHostingController(rootView: content())
return controller.view
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
In Container, declare a binding var so you can pass the bottomState to the Container View:
struct Container: View {
#Binding var bottomState: CGFloat
.
.
.
.
}
Dont forget to pass bottomState to your Container View wherever you use it:
Container(bottomState: $bottomState)
Now in your Container View, you just need to declare that you don't want an animation while bottomState is being changed:
Text("CONTAINER")
.frame(maxWidth: .infinity, maxHeight: 200)
.animation(nil, value: bottomState) // You Need To Add This
.animation(.spring())
In .animation(nil, value: bottomState), by nil you are asking SwiftUI for no animations, while value of bottomState is being changed.
This approach is tested using Xcode 12 GM, iOS 14.0.1.
You must use the modifiers of the Text in the order i put them. that means that this will work:
.animation(nil, value: bottomState)
.animation(.spring())
but this won't work:
.animation(.spring())
.animation(nil, value: bottomState)
I also made sure that adding .animation(nil, value: bottomState) will only disable animations when bottomState is being changed, and the animation .animation(.spring()) should always work if bottomState is not being changed.
So this is my updated answer. I don't think there is a pretty way to do it so now I am doing it with a custom Button.
import SwiftUI
struct ContentView: View {
#State var isShowing = false
var body: some View {
Text("Hello, world!")
.padding()
.onTapGesture(count: 1, perform: {
withAnimation(.spring()) {
self.isShowing.toggle()
}
})
.showModal(isShowing: self.$isShowing)
}
}
extension View {
func showModal(isShowing: Binding<Bool>) -> some View {
ViewOverlay(isShowing: isShowing, presenting: { self })
}
}
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
#State var isDragging = false
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
if isShowing {
Container()
.background(Color.red)
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
isDragging = true
bottomState = value.translation.height
}
.onEnded { _ in
isDragging = false
if bottomState > 50 {
withAnimation(.spring()) {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
}
}
}
}
struct Container: View {
var body: some View {
CustomButton(action: {}, label: {
Text("Pressme")
})
.frame(maxWidth: .infinity, maxHeight: 200)
}
}
struct CustomButton<Label >: View where Label: View {
#State var isPressed = false
var action: () -> ()
var label: () -> Label
var body: some View {
label()
.scaleEffect(self.isPressed ? 0.9 : 1.0)
.gesture(DragGesture(minimumDistance: 0).onChanged({_ in
withAnimation(.spring()) {
self.isPressed = true
}
}).onEnded({_ in
withAnimation(.spring()) {
self.isPressed = false
action()
}
}))
}
}
The problem is that you can't use implicit animations inside the container as they will be animated when it moves. So you need to explicitly set an animation using withAnimation also for the button pressed, which I now did with a custom Button and a DragGesture.
It is the difference between explicit and implicit animation.
Take a look at this video where this topic is explored in detail:
https://www.youtube.com/watch?v=3krC2c56ceQ&list=PLpGHT1n4-mAtTj9oywMWoBx0dCGd51_yG&index=11

Is the SwiftUI tapGesture super greedy?

Swift 5, iOS 13
This code works if I change the second Gesture to say a LONG Press, but leave them both as tap and it never shows the red box? Am I going mad?
import SwiftUI
struct SwiftUIViewQ: View {
#State var swap: Bool = false
var body: some View {
VStack {
if swap {
SquareView(fillColor: Color.red)
.onTapGesture {
self.swap = false
}
} else {
SquareView(fillColor: Color.blue)
.onTapGesture {
self.swap = true
}
}
}
}
}
struct SquareView: View {
#State var fillColor: Color
var body: some View {
Rectangle()
.fill(fillColor)
.frame(width: 128, height: 128)
.onAppear {
print("fillColor \(self.fillColor)")
}
}
}
Oddly if I add an onAppear to the first view, it works... if I than add an onAppear to the second it breaks it again..
var fillColor doesn't need #State, just remove that and it'll work fine
struct SquareView: View {
var fillColor: Color
var body: some View {
Rectangle()
.fill(fillColor)
.frame(width: 128, height: 128)
.onAppear {
print("fillColor \(self.fillColor)")
}
}
}

In SwiftUI how can I animate a button offset when displayed

In SwiftUI, I want a button to appear from off screen by dropping in from the top into a final position when the view is initially displayed, I'm not asking for animation when the button is pressed.
I have tried:
Button(action: {}) {
Text("Button")
}.offset(x: 0.0, y: 100.0).animation(.basic(duration: 5))
but no joy.
If you would like to play with offset, this can get you started.
struct ContentView : View {
#State private var offset: Length = 0
var body: some View {
Button(action: {}) { Text("Button") }
.offset(x: 0.0, y: offset)
.onAppear {
withAnimation(.basic(duration: 5)) { self.offset = 100.0 }
}
}
}
I first suggested a .transition(.move(.top)), but I am updating my answer. Unless your button is on the border of the screen, it may not be a good fit. The move is limited to the size of the moved view. So you may need to use offset after all!
Note that to make it start way out of the screen, the initial value of offset can be negative.
First of all you need to create a transition. You could create an extension for AnyTransition or just create a variable. Use the move() modifier to tell the transition to move the view in from a specific edge
let transition = AnyTransition.move(edge: .top);
This alone only works if the view is at the edge of the screen. If your view is more towards the center you can use the combined() modifier to combine another transition such as offset() to add additional offset
let transition = AnyTransition
.move(edge: .top)
.combined(with:
.offset(
.init(width: 0, height: 100)
)
);
This transition will be for both showing and removing a view although you can use AnyTransition.asymmetric() to use different transitions for showing and removing a view
Next create a showButton bool (name this whatever) which will handle showing the button. This will use the #State property wrapper so SwiftUI will refresh the UI when changed.
#State var showButton: Bool = false;
Next you need to add the transition to your button and wrap your button within an if statement checking if the showButton bool is true
if (self.showButton == true) {
Button(action: { }) {
Text("Button")
}
.transition(transition);
}
Finally you can update the showButton bool to true or false within an animation block to animate the button transition. toggle() just reverses the state of the bool
withAnimation {
self.showButton.toggle();
}
You can put your code in onAppear() and set the bool to true so the button is shown when the view appears. You can call onAppear() on most things like a VStack
.onAppear {
withAnimation {
self.showButton = true;
}
}
Check the Apple docs to see what is available for AnyTransition https://developer.apple.com/documentation/swiftui/anytransition
Presents a message box on top with animation:
import SwiftUI
struct MessageView: View {
#State private var offset: CGFloat = -200.0
var body: some View {
VStack {
HStack(alignment: .center) {
Spacer()
Text("Some message")
.foregroundColor(Color.white)
.font(Font.system(.headline).bold())
Spacer()
}.frame(height: 100)
.background(Color.gray.opacity(0.3))
.offset(x: 0.0, y: self.offset)
.onAppear {
withAnimation(.easeOut(duration: 1.5)) { self.offset = 000.0
}
}
Spacer()
}
}
}
For those that do want to start from a Button that moves when you tap on it, try this:
import SwiftUI
struct ContentView : View {
#State private var xLoc: CGFloat = 0
var body: some View {
Button("Tap me") {
withAnimation(.linear(duration: 2)) { self.xLoc+=50.0 }
}.offset(x: xLoc, y: 0.0)
}
}
Or alternatively (can replace Text with anything):
Button(action: {
withAnimation(.linear(duration: 2)) { self.xLoc+=50.0 }
} )
{ Text("Tap me") }.offset(x: xLoc, y: 0.0)