Swiftui - Animations Triggered Inside a View That's in a List Doesn't Animate the List as Well

SwiftUI - Animations triggered inside a View that's in a list doesn't animate the list as well

The solution is just to make height animatable continuously, by providing explicit animatable modifier for this.

Here is working approach. Tested with Xcode 11.4 / iOS 13.4.

demo

Implementation of simple helper modifier

struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0

var animatableData: CGFloat {
get { height }
set { height = newValue }
}

func body(content: Content) -> some View {
content.frame(height: height)
}
}

Modified using view (other parts unchanged)

struct SubView: View {
@State var change: Bool = false

var body: some View {
Rectangle()
.frame(width: 200)
.modifier(AnimatingCellHeight(height: change ? 300 : 200))
.foregroundColor(Color.red)
.onTapGesture {
withAnimation {
self.change.toggle()
}
}
}
}

Stop views bouncing in a SwiftUI List row when adding child views?

Ironically I realise that Asperi has referred me to his solution for an almost identical issue I raised 18mth ago! Now feeling a little foolish but it explains the vague feeling I had that I'd come across this problem before!

However, I have discovered that there is an important difference. If you're working with a 'vanilla' stack container view inside a List or Form, adjusting alignment is all you need to do to achieve a smooth animation of one view being disclosed by another. Animating the frame of the container view works absolutely fine but it's not necessary. However, as soon as you try the same thing inside a DisclosureGroup, the views that form your label start jumping around regardless of whether you animate alignment guides, frames, etc, etc and you have to animate the parent view height explicitly.

So, slide out views within a simple List or Form cell are easy (using AlignmentGuide) but you need to add in AnimatableValue for the cell height when you try the same thing in a more specialised view container. If I was to guess I suspect this is because there is some conflict with Apple's own disclosure implementation.

So, thanks to Asperi for reminding me how to do this :-)

Code examples of two ways to achieve the effect below (I prefer the appearance of the alignment guide animation personally)

Adjusting the frame of the view to be disclosed:

struct DisclosureWithFrameAnimationReveal: View {
@Namespace private var nspace
@State private var willDisplayControl = false

var body: some View {

DisclosureGroup(
content: {
ForEach(0..<2) { index in
Text("Item")
.padding(6)
.background {
Rectangle()
.foregroundColor(.yellow)
}
}
},
label: {
VStack {
HStack {
Button(action: {
withAnimation(.easeInOut) { willDisplayControl.toggle() }
})
{
Image(systemName: "plus.circle")
}
.buttonStyle(PlainButtonStyle())

Color(.red)
.frame(width: 100, height: 40)
Spacer()
}
.padding(.top, 4)
.background()

HStack {
Color(.blue)
.frame(width: 100, height: willDisplayControl ? 40 : 0)
Spacer()
}
.opacity(willDisplayControl ? 1 : 0)
}
.modifier(AnimatableCellHeight(height: willDisplayControl ? 88 : 44))

}
)
}
}

Alignment guide animation:

struct DisclosureWithAlignmentReveal: View {
@Namespace private var nspace
@State private var willDisplayControl = false

var body: some View {
DisclosureGroup(
content: {
ForEach(0..<2) { index in
Text("Item")
.padding(6)
.background {
Rectangle()
.foregroundColor(.yellow)
}
}
},
label: {
ZStack(alignment: .top) {
HStack {
Button(action: {
withAnimation(.easeInOut) { willDisplayControl.toggle() }
})
{
Image(systemName: "plus.circle")
}
.buttonStyle(PlainButtonStyle())

Color(.red)
.frame(width: 100, height: 40)
Spacer()
}
.zIndex(1)
.padding(.top, 4)
.background()

HStack {
Color(.blue)
.frame(width: 100, height: 40)
Spacer()
}
.alignmentGuide(.top, computeValue: { d in d[.top] - (willDisplayControl ? 46 : 0) })
.opacity(willDisplayControl ? 1 : 0)
.zIndex(0)
}
.modifier(AnimatableCellHeight(height: willDisplayControl ? 88 : 44))
}
)
}
}

And, finally, the AnimatableValue implementation:

struct AnimatableCellHeight: AnimatableModifier {
var height: CGFloat = 0

var animatableData: CGFloat {
get { height }
set { height = newValue }
}

func body(content: Content) -> some View {
content.frame(height: height)
}
}

Final solution

SwiftUI animation not working using animation(_:value:)

The difference between animation(_:) and animation(_:value:) is straightforward. The former is implicit, and the latter explicit. The implicit nature of animation(_:) meant that anytime ANYTHING changed, it would react. The other issue it had was trying to guess what you wanted to animate. As a result, this could be erratic and unexpected. There were some other issues, so Apple has simply deprecated it.

animation(_:value:) is an explicit animation. It will only trigger when the value you give it changes. This means you can't just stick it on a view and expect the view to animate when it appears. You need to change the value in an .onAppear() or use some value that naturally changes when a view appears to trigger the animation. You also need to have some modifier specifically react to the changed value.

struct ContentView: View {
@State var isOn = false
//The better route is to have a separate variable to control the animations
// This prevents unpleasant side-effects.
@State private var animate = false

var body: some View {
VStack {
Text("I don't change.")
.padding()
Button("Press me, I do change") {
isOn.toggle()
animate = false
// Because .opacity is animated, we need to switch it
// back so the button shows.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
animate = true
}
}
// In this case I chose to animate .opacity
.opacity(animate ? 1 : 0)
.animation(.easeIn, value: animate)
.frame(width: 300, height: 400)
// If you want the button to animate when the view appears, you need to change the value
.onAppear { animate = true }
}
}
}

How can I animate individual rows in SwiftUI List?

The key seems to be using the indices from the ForEach loop to set the times at which the animations appear.

Below is the code. The toggle switch just resets the state to show the animation:

struct GuideListView: View {
let data = ["One", "Two", "Three", "Four"]
@State var showListItems = false
@State var animationDelay = 0.5
// definitions of viewRouter, data etc.

var body: some View {

VStack {

// other items, navLink etc.
Toggle("Show List Items", isOn: $showListItems)

List {

ForEach(data.indices) { index in

Button(action: {
// navigation action
}, label: {
Text(data[index])
})
.opacity(showListItems ? 1 : 0)
.animation(Animation.easeOut(duration: 0.6).delay(animationDelay * Double(index)), value: showListItems)

} //: ForEach

} //: List

} //: VStack
}
}

SwiftUI List animation when changing data source

If your button is wrapped as you suggested and I added a simple direction boolean:

Button(action: {
withAnimation {
slideRight = true
self.previous()
}
}) {
Text("<")
}

And opposite for the other direction:

Button(action: {
withAnimation {
slideRight = false
self.next()
}
}) {
Text(">")
}

Then you can transition your view like this:

List(viewingModels, id: \.self) { model in
Text(model)
}
.id(UUID())
.transition(.asymmetric(insertion: .move(edge: slideRight ? .leading : .trailing),
removal: .move(edge: slideRight ? .trailing : .leading)))

Note that in order for the list to not animate, we need to give the list a new unique ID each time, see this article: https://swiftui-lab.com/swiftui-id/

UPDATE:

I wanted to provide the full shortened code that works, also removed the UUID() usage based on comments below.

import SwiftUI

struct ContentView: View {
private let models = [
["a", "b", "c", "d", "e", "f"],
["g", "h"],
["i", "j", "k", "l"],
]

@State private var selectedCategory = 0
@State private var slideRight = true

private var viewingModels: [String] {
models[selectedCategory]
}

var body: some View {
VStack(spacing: 0.0) {
HStack {
Button(action: {
withAnimation {
if(self.selectedCategory - 1 < 0) { self.selectedCategory = self.models.count - 1 }
else { self.selectedCategory -= 1 }
self.slideRight = true
}
}) {
Image(systemName: "arrow.left")
}

Text("\(selectedCategory + 1)")

Button(action: {
withAnimation {

if(self.selectedCategory + 1 > self.models.count - 1) { self.selectedCategory = 0 }
else { self.selectedCategory += 1 }
self.slideRight = false
}
}) {
Image(systemName: "arrow.right")
}
}.font(.title)

List(viewingModels, id: \.self) { model in
Text(model)
}
.id(selectedCategory)
.transition(.asymmetric(insertion: .move(edge: slideRight ? .leading : .trailing),
removal: .move(edge: slideRight ? .trailing : .leading)))
}.padding(10)
}
}

animated list change

How to stop SwiftUI DragGesture from animating subviews

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

demo

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


Related Topics



Leave a reply



Submit