`withAnimation` only does animation once when adding first item to #State array - swift

My goal is to have control over the type of animation when an object is added to the #State events array.
withAnimation only occurs on the first append to the events array. It is then ignored on additional appends.
I'm currently running this on Xcode 11 beta 4
I've tried adding the calling DispatchQueue.main.async, having the animation on the Text() object.
If I use a list it performs animation on addition, however I don't know how to modify those animations.
Goal
Have text slide in with each append and fade out on each remove.
struct Event: Identifiable {
var id = UUID()
var title: String
}
struct ContentView: View {
#State
var events = [Event]()
var body: some View {
VStack {
ScrollView {
ForEach(events) { event in
Text(event.title)
.animation(.linear(duration: 2))
}
}
HStack {
Button(action: {
withAnimation(.easeOut(duration: 1.5)) {
self.events.append(Event(title: "Animate Please"))
}
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}
I'm expecting that each append has an animation that is described in the withAnimation block.

When SwiftUI layouts and animations behave in ways you think are not correct, I suggest you add borders. The outcome may surprise you and point you directly into the cause. In most cases, you'll see that SwiftUI was actually right! As in your case:
Start by adding borders:
ScrollView {
ForEach(events) { event in
Text(event.title)
.border(Color.red)
.animation(.linear(duration: 2))
}.border(Color.blue)
}.border(Color.green)
When you run your app, you'll see that before adding your first array element, the ScrollView is collapsed into zero width. That is correct, as the ScrollView is empty. However, when you add your first element, it needs to be expanded to accommodate the "Animate Please" text. The Text() view also starts with zero width, but as its containing ScrollView grows, it does too. These are the changes that get animated.
Now, when you add your second element, there is nothing to animate. The Text() view is placed with its final size right from the start.
If instead of "Animate Please", you change your code to use a random length text, you will see that when adding a largest view, animations do occur. This is because ScrollView needs to expand again:
self.events.append(Event(title: String(repeating: "A", count: Int.random(in: 0..<20))))
What next: You have not explained in your question what animation you expect to see. Is it a fade-in? A slide? Note that in addition to animations, you may define transitions, which determines the type of animation to perform when a view is added or removed from your hierarchy.
If after putting these tips into practice, you continue to struggle, I suggest you edit your question and tell us exactly what animation would you like to see when adding a new element to your array.
UPDATE
According to your comments, you want the text to slide. The simplest form, is using a transition. Unfortunately, the ScrollView seems to disable transitions on its children. I don't know if that is intended or a bug. Anyway, here I post two methods. One with transitions (does not work with ScrollView) and one using only animations, which does work inside a ScrollView, but requires more code:
With transitions (does not work inside a ScrollView)
struct ContentView: View {
#State private var events = [Event]()
var body: some View {
VStack {
ForEach(events) { event in
// A simple slide
Text(event.title).transition(.slide).animation(.linear(duration: 2))
// To specify slide direction
Text(event.title).transition(.move(edge: .trailing)).animation(.linear(duration: 2))
// Slide combined with fade-in
Text(event.title).transition(AnyTransition.slide.combined(with: .opacity)).animation(.linear(duration: 2))
}
Spacer()
HStack {
Button(action: {
self.events.append(Event(title: "Animate Please"))
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}
Without transitions (works inside a ScrollView):
struct Event: Identifiable {
var id = UUID()
var title: String
var added: Bool = false
}
struct ContentView: View {
#State var events = [Event]()
var body: some View {
VStack {
ScrollView {
ForEach(0..<events.count) { i in
// A simple slide
Text(self.events[i].title).animation(.linear(duration: 2))
.offset(x: self.events[i].added ? 0 : 100).opacity(self.events[i].added ? 1 : 0)
.onAppear {
self.events[i].added = true
}
}
HStack { Spacer() } // This forces the ScrollView to expand horizontally from the start.
}.border(Color.green)
HStack {
Button(action: {
self.events.append(Event(title: "Animate Please"))
}) {
Image(systemName: "plus.circle.fill").resizable().frame(width: 40, height: 40, alignment: .center)
}
}
}
}
}

Related

SwiftUI translate and rotate view with matchedGeometryEffect strange behavior

I'm trying to simulate dealing cards in SwiftUI. I'm testing with one card, and the goal is to animate the card in the center (image1) to one of the sides (image2). I would like that the animation would rotate and translate the card simultaneously, but with this code the card rotates immediately without animation and then it translates animatedly. Any idea to get the rotation and translation effects simultaneously into the animation?
import SwiftUI
struct Card: Identifiable {
var id: String {
return value
}
let value: String
var dealt: Bool = false
}
struct CardView: View {
let card: Card
var flipped: Bool = false
var body: some View {
ZStack {
Color.white
Text("\(card.value)")
.padding(4)
Color.red.opacity(flipped ? 0.0 : 1.0)
}
.border(.black, width: 2)
}
}
struct CardsTableView: View {
#Namespace private var dealingNamespace
#State var card = Card(value: "1", dealt: false)
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.clear)
.border(.black, width: 2)
.padding(10)
VStack {
ZStack {
centerCard
lateralCard
}
Spacer()
Button {
withAnimation(.linear(duration: 1)) {
card.dealt.toggle()
}
} label: {
Text("Deal")
}
.padding()
}
}
}
var centerCard: some View {
VStack {
Spacer()
if !card.dealt {
CardView(card: card)
.frame(width: 40, height: 70)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
}
Spacer()
}
}
var lateralCard: some View {
HStack {
Spacer()
if card.dealt {
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.frame(width: 40, height: 70)
.rotationEffect(.degrees(-90))
.transition(AnyTransition.asymmetric(insertion: .identity, removal: .opacity))
}
}
.padding(.trailing, 20)
}
}
The matchedGeometryEffect modifier doesn't know about the rotationEffect modifier, so neither view's rotation is animated during the transition. I'll explain how to get the animation you want in two ways: using transitions and using “slots”. Both solutions produce this animation:
Using transitions
You can use a custom .modifier transition to animate the rotation. I wouldn't do it this way, but since it has a similar structure as the code you posted, I'll explain it first.
For the sake of this answer, let's simplify CardView:
struct CardView: View {
var body: some View {
Text("C")
.foregroundColor(.black)
.padding()
.background(Color.white)
.border(Color.black, width: 1)
}
}
To animate rotation, we need a ViewModifier type that applies the rotation effect:
struct CardRotationModifier: ViewModifier {
var angle: Angle
func body(content: Content) -> some View {
content.rotationEffect(angle)
}
}
Here's CardTableView:
struct CardTableView: View {
#Namespace var namespace
#State var isSide = false
var body: some View {
ZStack {
VStack {
if !isSide {
topCard
}
Spacer()
}
HStack {
Spacer()
if isSide {
sideCard
}
}
}
.padding()
.background(Color.mint)
.onTapGesture {
withAnimation(.linear) {
isSide.toggle()
}
}
}
}
And finally here are the top and side card views:
extension CardTableView {
var topCard: some View {
CardView()
.matchedGeometryEffect(id: 0, in: namespace)
.transition(
.modifier(
active: CardRotationModifier(angle: .degrees(90)),
identity: CardRotationModifier(angle: .zero)))
}
var sideCard: some View {
CardView()
.matchedGeometryEffect(id: 0, in: namespace)
.transition(
.modifier(
active: CardRotationModifier(angle: .zero),
identity: CardRotationModifier(angle: .degrees(90))))
}
}
Note that the side card doesn't have a .rotationEffect. Instead, both cards have a transition that applies CardRotationModifier. SwiftUI applies the active modifier at the start of an entrance transition and the end of an exit transition. It applies the identity modifier at the end of an entrance transition, the start of an exit transition, and the entire time the view is “at rest” (present and not transitioning). So the top card normally has rotation zero, and the side card normally has rotation 90°, and each card is animated to the other's rotation during a transition.
What I don't like about this solution is that the transitions are configured specifically for moving a card between the top and side positions. The transition on the top position knows about the rotation of the side position, and vice versa. So what if you want to add a left-side position with a rotation of -90°? You've got a problem. Now you need to dynamically set the transition of each position based on where the card is moving from and to. Every position needs to know details of every other position, so it can be O(N) work to add another position.
Using slots
Instead, I would use what I think of as “slots”: put a hidden view at each possible position (“slot”) of a card. Then, use a view with a persistent identity to draw the card, and tell that persistent view to match the geometry of whichever slot it should occupy.
So, we need a way to identify each slot:
enum Slot: Hashable {
case top
case side
}
Now CardTableView lays out a subview for each slot, and a view for the card:
struct CardTableView: View {
#Namespace var namespace
#State var isSide = false
var body: some View {
ZStack {
topSlot
sideSlot
card
}
.padding()
.background(Color.mint)
.onTapGesture {
withAnimation(.linear) {
isSide.toggle()
}
}
}
}
Here are the slot subviews:
extension CardTableView {
var topSlot: some View {
VStack {
CardView()
.hidden()
.matchedGeometryEffect(id: Slot.top, in: namespace)
Spacer()
}
}
var sideSlot: some View {
HStack {
Spacer()
CardView()
.hidden()
.matchedGeometryEffect(id: Slot.side, in: namespace)
}
}
}
And here is the card subview:
extension CardTableView {
var card: some View {
CardView()
.rotationEffect(isSide ? .degrees(90): .zero)
.matchedGeometryEffect(
id: isSide ? Slot.side : Slot.top,
in: namespace, isSource: false)
}
}
Notice that now there are no transitions anywhere, and none of the slots knows anything about the other slots. If you want to add another slot, it's a matter of defining another slot subview, adding that new slot subview to the CardTableView ZStack, and updating the card subview to know how to pose itself in the new slot. None of the existing slot subviews are affected. It's O(1) work to add a new slot.

How to animate the removal of a view created with a ForEach loop getting its data from an ObservableObject in SwiftUI

The app has the following setup:
My main view creates a tag cloud using a SwiftUI ForEach loop.
The ForEach gets its data from the #Published array of an ObservableObject called TagModel. Using a Timer, every three seconds the ObservableObject adds a new tag to the array.
By adding a tag the ForEach gets triggered again and creates another TagView. Once more than three tags have been added to the array, the ObservableObject removes the first (oldest) tag from the array and the ForEach removes that particular TagView.
With the following problem:
The creation of the TagViews works perfect. It also animates the way it's supposed to with the animations and .onAppear modifiers of the TagView. However when the oldest tag is removed it does not animate its removal. The code in .onDisappear executes but the TagView is removed immediately.
I tried the following to solve the issue:
I tried to have the whole appearing and disappearing animations of TagView run inside the .onAppear by using animations that repeat and then autoreverse.
It sort of works but this way there are two issues.
First, if the animation is timed too short, once the animation finishes and the TagView is removed, will show up for a short moment without any modifiers applied, then it will be removed.
Second, if I set the animation duration longer the TagView will be removed before the animation has finished.
In order for this to work I'd need to time the removal and the duration of the animation very precisely, which would make the TagView very dependent on the Timer and this doesn't seem to be a good solution.
Another solution I tried was finding something similar to self.presentationMode.wrappedValue.dismiss() using the #Environment(\.presentationMode) variable and somehow have the TagView remove itself after the .onAppear animation has finished. But this only works if the view has been created in an navigation stack and I couldn't find any other way to have a view destroy itself. Also I assume that would again cause issue as soon as TagModel updates its array.
I read several other S.O. solution that pointed towards the enumeration of the data in the ForEach loop. But I'm creating each TagView as its own object, I'd assume this should not be the issue and I'm not sure how I'd have to implement this if this is part of the issue.
Here is the simplified code of my app that can be run in an iOS single view SwiftUI project.
import SwiftUI
struct ContentView: View {
private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
#ObservedObject var tagModel = TagModel()
var body: some View {
ZStack {
ForEach(tagModel.tags, id: \.self) { label in
TagView(label: label)
}
.onReceive(timer) { _ in
self.tagModel.addNextTag()
if tagModel.tags.count > 3 {
self.tagModel.removeOldestTag()
}
}
}
}
}
class TagModel: ObservableObject {
#Published var tags = [String]()
func addNextTag() {
tags.append(String( Date().timeIntervalSince1970 ))
}
func removeOldestTag() {
tags.remove(at: 0)
}
}
struct TagView: View {
#State private var show: Bool = false
#State private var position: CGPoint = CGPoint(x: Int.random(in: 50..<250), y: Int.random(in: 10..<25))
#State private var offsetY: CGFloat = .zero
let label: String
var body: some View {
let text = Text(label)
.opacity(show ? 1.0 : 0.0)
.scaleEffect(show ? 1.0 : 0.0)
.animation(Animation.easeInOut(duration: 6))
.position(position)
.offset(y: offsetY)
.animation(Animation.easeInOut(duration: 6))
.onAppear() {
show = true
offsetY = 100
}
.onDisappear() {
show = false
offsetY = 0
}
return text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
It is not clear which effect do you try to achieve, but on remove you should animate not view internals, but view itself, ie. in parent, because view remove there and as-a-whole.
Something like (just direction where to experiment):
var body: some View {
ZStack {
ForEach(tagModel.tags, id: \.self) { label in
TagView(label: label)
.transition(.move(edge: .leading)) // << here !! (maybe asymmetric needed)
}
.onReceive(timer) { _ in
self.tagModel.addNextTag()
if tagModel.tags.count > 3 {
self.tagModel.removeOldestTag()
}
}
}
.animation(Animation.easeInOut(duration: 1)) // << here !! (parent animates subview removing)

SwiftUI: Animate changes in List without animating content changes

I have a simple app in SwiftUI that shows a List, and each item is a VStack with two Text elements:
var body: some View {
List(elements) { item in
NavigationLink(destination: DetailView(item: item)) {
VStack(alignment: .leading) {
Text(item.name)
Text(self.distanceString(for: item.distance))
}
}
}
.animation(.default)
}
The .animate() is in there because I want to animate changes to the list when the elements array changes. Unfortunately, SwiftUI also animates any changes to content, leading to weird behaviour. For example, the second Text in each item updates quite frequently, and an update will now shortly show the label truncated (with ... at the end) before updating to the new content.
So how can I prevent this weird behaviour when I update the list's content, but keep animations when the elements in the list change?
In case it's relevant, I'm creating a watchOS app.
The following should disable animations for row internals
VStack(alignment: .leading) {
Text(item.name)
Text(self.distanceString(for: item.distance))
}
.animation(nil)
The answer by #Asperi fixed the issue I was having also (Upvoted his answer as always).
I had an issue where I was animating the whole screen in using the below: AnyTransition.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top))
And all the Text() and Button() sub views where also animating in weird and not so wonderful ways. I used animation(nil) to fix the issue after seeing Asperi's answer. However the issue was that my Buttons no longer animated on selection, along with other animations I wanted.
So I added a new State variable to turn on and off the animations of the VStack. They are off by default and after the view has been animated on screen I enable them after a small delay:
struct QuestionView : View {
#State private var allowAnimations : Bool = false
var body : some View {
VStack(alignment: .leading, spacing: 6.0) {
Text("Some Text")
Button(action: {}, label:Text("A Button")
}
.animation(self.allowAnimations ? .default : nil)
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.allowAnimations = true
}
}
}
}
Just adding this for anyone who has a similar issue to me and needed to build on Asperi's excellent answer.
Thanks to #Brett for the delay solution. My code needed it in several places, so I wrapped it up in a ViewModifier.
Just add .delayedAnimation() to your view.
You can pass parameters for defaults other than one second and the default animation.
import SwiftUI
struct DelayedAnimation: ViewModifier {
var delay: Double
var animation: Animation
#State private var animating = false
func delayAnimation() {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.animating = true
}
}
func body(content: Content) -> some View {
content
.animation(animating ? animation : nil)
.onAppear(perform: delayAnimation)
}
}
extension View {
func delayedAnimation(delay: Double = 1.0, animation: Animation = .default) -> some View {
self.modifier(DelayedAnimation(delay: delay, animation: animation))
}
}
In my case any of the above resulted in strange behaviours. The solution was to animate the action that triggered the change in the elements array instead of the list. For example:
#State private var sortOrderAscending = true
// Your list of elements with some sorting/filtering that depends on a state
// In this case depends on sortOrderAscending
var elements: [ElementType] {
let sortedElements = Model.elements
if (sortOrderAscending) {
return sortedElements.sorted { $0.name < $1.name }
} else {
return sortedElements.sorted { $0.name > $1.name }
}
}
var body: some View {
// Your button or whatever that triggers the sorting/filtering
// Here is where we use withAnimation
Button("Sort by name") {
withAnimation {
sortOrderAscending.toggle()
}
}
List(elements) { item in
NavigationLink(destination: DetailView(item: item)) {
VStack(alignment: .leading) {
Text(item.name)
}
}
}
}

SwiftUI - how to get coordinate/position of clicked Button

Short version: How do I get the coordinates of a clicked Button in SwiftUI?
I'm looking for something like this (pseudo code) where geometry.x is the position of the clicked button in the current view:
GeometryReader { geometry in
return Button(action: { self.xPos = geometry.x}) {
HStack {
Text("Sausages")
}
}
}
Long version: I'm beginning SwiftUI and Swift so wondering how best to achieve this conceptually.
To give the concrete example I am playing with:
Imagine a tab system where I want to move an underline indicator to the position of a clicked button.
[aside]
There is a answer in this post that visually does what I am going for but it seems rather complicated: How to make view the size of another view in SwiftUI
[/aside]
Here is my outer struct which builds the tab bar and the rectangle (the current indicator) I am trying to size and position:
import SwiftUI
import UIKit
struct TabBar: View {
var tabs:ITabGroup
#State private var selected = "Popular"
#State private var indicatorX: CGFloat = 0
#State private var indicatorWidth: CGFloat = 10
#State private var selectedIndex: Int = 0
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 0) {
ForEach(tabs.tabs) { tab in
EachTab(tab: tab, choice: self.$selected, tabs: self.tabs, x: self.$indicatorX, wid: self.$indicatorWidth)
}
}.frame(minWidth: 0, maxWidth: .infinity, minHeight: 40, maxHeight: 40).padding(.leading, 10)
.background(Color(UIColor(hex: "#333333")!))
Rectangle()
.frame(width: indicatorWidth, height: 3 )
.foregroundColor(Color(UIColor(hex: "#1fcf9a")!))
.animation(Animation.spring())
}.frame(height: 43, alignment: .leading)
}
}
Here is my struct that creates each tab item and includes a nested func to get the width of the clicked item:
struct EachTab: View {
// INCOMING!
var tab: ITab
#Binding var choice: String
var tabs: ITabGroup
#Binding var x: CGFloat
#Binding var wid: CGFloat
#State private var labelWidth: CGRect = CGRect()
private func tabWidth(labelText: String, size: CGFloat) -> CGFloat {
let label = UILabel()
label.text = labelText
label.font = label.font.withSize(size)
let labelWidth = label.intrinsicContentSize.width
return labelWidth
}
var body: some View {
Button(action: { self.choice = self.tab.title; self.x = HERE; self.wid = self.tabWidth(labelText: self.choice, size: 13)}) {
HStack {
// Determine Tab colour based on whether selected or default within black or green rab set
if self.choice == self.tab.title {
Text(self.tab.title).foregroundColor(Color(UIColor(hex: "#FFFFFF")!)).font(.system(size: 13)).padding(.trailing, 10).animation(nil)
} else {
Text(self.tab.title).foregroundColor(Color(UIColor(hex: "#dddddd")!)).font(.system(size: 13)).padding(.trailing, 10).animation(nil)
}
}
}
// TODO: remove default transition fade on button click
}
}
Creating a non SwiftUI UILabel to get the width of the Button seems a bit wonky. Is there a better way?
Is there a simple way to get the coordinates of the clicked SwiftUI Button?
You can use a DragGesture recogniser with a minimum drag distance of 0, which provides you the location info. However, if you combine the DragGesture with your button, the drag gesture won't be triggered on normal clicks of the button. It will only be triggered when the drag ends outside of the button.
You can get rid of the button completely, but of course then you lose the default button styling.
The view would look like this in that case:
struct MyView: View {
#State var xPos: CGFloat = 0
var body: some View {
GeometryReader { geometry in
HStack {
Text("Sausages: \(self.xPos)")
}
}.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { dragGesture in
self.xPos = dragGesture.location.x
})
}
}
The coordinateSpace parameter specifies if you want the touch position in .local or .global space. In the local space, the position is relative to the view that you've attached the gesture to. For example, if I had a Text view in the middle of the screen, my local y position would be almost 0, whereas my global y would be half of the screen height.
This tripped me up a bit, but this example shows the idea:
struct MyView2: View {
#State var localY: CGFloat = 0
#State var globalY: CGFloat = 0
var body: some View {
VStack {
Text("local y: \(self.localY)")
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local).onEnded { dragGesture in
self.localY = dragGesture.location.y
})
Text("global y: \(self.globalY)")
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { dragGesture in
self.globalY = dragGesture.location.y
})
}
}
}
struct ContentView: View {
var body: some View {
VStack {
Button(action: { print("Button pressed")}) { Text("Button") }
}.simultaneousGesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onEnded { print("Changed \($0.location)") })
}
}
This solution seems to work, add a simultaneous gesture on Button, unfortunatelly it does not work if the Button is places in a Form
Turns out I solved this problem by adapting the example on https://swiftui-lab.com/communicating-with-the-view-tree-part-2/
The essence of the technique is using anchorPreference which is a means of sending data about one view back up the chain to ancestral views. I couldn't find any docs on this in the Apple world but I can attest that it works.
I'm not adding code here as the reference link also includes explanation that I don't feel qualified to re-iterate here!

Sliding one SwiftUI view out from underneath another

I'm attempting to construct an animation using SwiftUI.
Start: [ A ][ B ][ D ]
End: [ A ][ B ][ C ][ D ]
The key elements of the animation are:
C should appear to slide out from underneath B (not expand from zero width)
The widths of all views are defined by subviews, and are not known
The widths of all subviews should not change during or after the animation (so, total view width is larger when in the end state)
I'm having a very difficult time satisfying all of these requirements with SwiftUI, but have been able to achieve similar affects with auto-layout in the past.
My first attempt was a transition using an HStack with layoutPriorities. This didn't really come close, because it affects the width of C during the animation.
My second attempt was to keep the HStack, but use a transition with asymmetrical move animations. This came really close, but the movement of B and C during the animation does not give the effect that C was directly underneath B.
My latest attempt was to scrap relying on an HStack for the two animating views, and use a ZStack instead. With this setup, I can get my animation perfect by using a combination of offset and padding. However, I can only get it right if I make the frame sizes of B and C known values.
Does anyone have any ideas on how to achieve this effect without requiring fixed frame sizes for B and C?
Since I originally replied to this question, I have been investigating GeometryReader, View Preferences and Anchor Preferences. I have assembled a detailed explanation that elaborates further. You can read it at: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/
Once you get the CCCCCCCC view geometry into the textRect variable, the rest is easy. You simply use the .offset(x:) modifier and clipped().
import SwiftUI
struct RectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect()
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
typealias Value = CGRect
}
struct ContentView : View {
#State private var textRect = CGRect()
#State private var slideOut = false
var body: some View {
return VStack {
HStack(spacing: 0) {
Text("AAAAAA")
.font(.largeTitle)
.background(Color.yellow)
.zIndex(4)
Text("BBBB")
.font(.largeTitle)
.background(Color.red)
.zIndex(3)
Text("I am a very long text")
.zIndex(2)
.font(.largeTitle)
.background(GeometryGetter())
.background(Color.green)
.offset(x: slideOut ? 0.0 : -textRect.width)
.clipped()
.onPreferenceChange(RectPreferenceKey.self) { self.textRect = $0 }
Text("DDDDDDDDDDDDD").font(.largeTitle)
.zIndex(1)
.background(Color.blue)
.offset(x: slideOut ? 0.0 : -textRect.width)
}.offset(x: slideOut ? 0.0 : +textRect.width / 2.0)
Divider()
Button(action: {
withAnimation(.basic(duration: 1.5)) {
self.slideOut.toggle()
}
}, label: {
Text("Animate Me")
})
}
}
}
struct GeometryGetter: View {
var body: some View {
GeometryReader { geometry in
return Rectangle()
.fill(Color.clear)
.preference(key: RectPreferenceKey.self, value:geometry.frame(in: .global))
}
}
}
It's hard to tell what exactly you're going for or what's not working. It would be easier to help you if you showed the "wrong" animation you came up with or shared your code.
Anyway, here's a take. I think it sort of does what you specified, though it's certainly not perfect:
Observations:
The animation relies on the assumptions that (A) and (B) together are wider than (C). Otherwise, parts of (C) would appear to the left of A at the start of the animation.
Similarly, the animation relies on the fact that there's no spacing between the views. Otherwise, (C) would be appear to the left of (B) when it's wider than (B).
It may be possible to solve both problems by placing an opaque underlay view in the hierarchy such that it is below (A), (B), and (D), but above (C). But I haven't thought this through.
The HStack seems to expand a tad more quickly than (C) is sliding in, which is why a white portion appears briefly. I didn't manage to eliminate this. I tried adding the same animation(.basic()) modifier to the HStack, the transition, the withAnimation call, and the VStack, but that didn't help.
The code:
import SwiftUI
struct ContentView: View {
#State var thirdViewIsVisible: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 0) {
Text("Lorem ").background(Color.yellow)
.zIndex(1)
Text("ipsum ").background(Color.red)
.zIndex(1)
if thirdViewIsVisible {
Text("dolor sit ").background(Color.green)
.zIndex(0)
.transition(.move(edge: .leading))
}
Text("amet.").background(Color.blue)
.zIndex(1)
}
.border(Color.red, width: 1)
Button(action: { withAnimation { self.thirdViewIsVisible.toggle() } }) {
Text("Animate \(thirdViewIsVisible ? "out" : "in")")
}
}
.padding()
.border(Color.green, width: 1)
}
}