Skip to content

Instantly share code, notes, and snippets.

@MarqueIV
Last active May 30, 2023 21:56
Show Gist options
  • Save MarqueIV/78ab8025d7e521f717cd0b0e10657532 to your computer and use it in GitHub Desktop.
Save MarqueIV/78ab8025d7e521f717cd0b0e10657532 to your computer and use it in GitHub Desktop.
Issue with animations when both adding an item while also trying to scroll to the end of the list
import SwiftUI
import Combine
@main
struct TestApp: App {
let itemManager = ItemManager()
var body: some Scene {
WindowGroup {
ItemListView(itemManager: itemManager)
.frame(minWidth: 600, minHeight: 450)
}
}
}
class ItemManager: ObservableObject {
static let defaultValues = [
"This",
"is",
"a",
"test"
]
init() {
self.reset()
}
@Published private(set) var items: [Item] = []
@discardableResult
func add(_ value: String) -> Item {
let newItem = Item(value)
items.append(newItem)
return newItem
}
@discardableResult
func removeLast() -> Item? {
guard !items.isEmpty else { return nil }
return items.removeLast()
}
func reset() {
items = Self.defaultValues.map{ value in Item(value) }
}
}
class Item: ObservableObject, Identifiable, Hashable {
init(_ value: String) {
self.value = value
}
@Published var value: String
let id = UUID()
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
static func ==(lhs: Item, rhs: Item) -> Bool {
return lhs === rhs
}
}
struct ItemListView: View {
@ObservedObject var itemManager: ItemManager
@State private var nextItemNo = 1
private let itemTransition = AnyTransition
.opacity
.combined(with: .move(edge: .bottom))
func getPaddingEdges(for item: Item) -> Edge.Set {
// Only the first item has top padding
item.id == itemManager.items.last?.id
? .all
: [.leading, .top, .trailing]
}
var body: some View {
VStack {
ScrollViewReader { scrollViewProxy in
ScrollView {
VStack(spacing: 0) {
ScrollViewProxy.TopAnchor
ForEach(itemManager.items) { item in
ItemView(item){ _ in // The value of the item has changed
withAnimation{
scrollViewProxy.scrollToBottom()
}
}
.padding(getPaddingEdges(for: item))
.transition(itemTransition)
}
ScrollViewProxy.BottomAnchor
}
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity) // Fill parent window
.background(.white)
HStack {
Group {
Button("Add New"){
defer { nextItemNo += 1 }
let newItem = itemManager.add("Item \(nextItemNo)")
Task {
try await Task.sleep(for: .seconds(2))
newItem.value += """
I
have
been
updated!
"""
}
#warning("This is the issue here...")
// This doesn't work unless I comment out line 169
// but then I lose the add/remove animations
//
// withAnimation {
// scrollViewProxy.scrollToBottom()
// }
}
Button("Remove Last"){
itemManager.removeLast()
}
Button("Reset Items"){
itemManager.reset()
}
Button("Scroll to Top"){
withAnimation {
scrollViewProxy.scrollToTop()
}
}
Button("Scroll to bottom"){
withAnimation {
scrollViewProxy.scrollToBottom()
}
}
}
.buttonStyle(.borderedProminent)
}
}
.padding()
}
.background(.thinMaterial)
.animation(.easeInOut, value: itemManager.items)
}
}
struct ItemView: View {
typealias ItemValueChangedHandler = (String) -> Void
init(_ item: Item, itemValueChangedHandler: ItemValueChangedHandler? = nil) {
self.item = item
self.itemValueChangedHandler = itemValueChangedHandler ?? { value in }
}
@ObservedObject private(set) var item: Item
let itemValueChangedHandler: ItemValueChangedHandler
var body: some View {
Text(item.value)
.font(.system(.title))
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(.white)
.background(.blue)
.onChange(of: item.value, perform: itemValueChangedHandler)
}
}
struct PlaceholderView: View { // Tiniest-possible view. Basically invisible. Used as placeholder
var body: some View {
Rectangle()
.fill(.clear)
.frame(width: .leastNonzeroMagnitude, height: .leastNonzeroMagnitude)
}
}
extension ScrollViewProxy {
static let topAnchorId = UUID()
static let bottomAnchorId = UUID()
static var TopAnchor: some View {
PlaceholderView()
.id(Self.topAnchorId)
}
static var BottomAnchor: some View {
PlaceholderView()
.id(Self.bottomAnchorId)
}
func scrollTo(_ item: Item, anchor: UnitPoint? = .top) {
scrollTo(item.id, anchor: anchor)
}
func scrollToTop() {
scrollTo(Self.topAnchorId, anchor: .top)
}
func scrollToBottom() {
scrollTo(Self.bottomAnchorId, anchor: .bottom)
}
}
@MarqueIV
Copy link
Author

MarqueIV commented May 30, 2023

Hey all. Seem to be having some issues getting animations working in regards to modifications to a list of items being displayed in either a List, or a ForEach in a ScrollView (as shown here.)

The TLDR is I'm trying to animate both the item being added (and removed), while simultaneously scrolling to the bottom of the list where the item is added to.

Above is a single-file, fully-working Swift app showing exactly what I'm trying to solve.

To use it, create a new Xcode SwiftUI project, and replace the .app file's contents with the above.

Specifically the issues are...

  1. If I have the .animation(.easeInOut, value: itemManager.items) set (line 169), then I can't use any of the scrollTo() calls. They simply do not work (whether wrapped in withAnimation or not, whether forced to the UI thread or not, nothing. They do not work.
  2. If I comment line 169 out, then I can animate the scrollTo calls, but now the views 'blip' in instantly, which looks bad.
  3. If I leave 169 commented out, but wrap the actual calls to add/remove items with animations, they again animate, but we're back to the scrollTo calls not working. (This leads me to believe wrapping the calls in withAnimation is basically what that view modifier is just doing automatically for me, but it's just a guess.)

So in a perfect world, the items would fade/slide in from the bottom when added, and the list would scroll down to the end at the same time showing the newly-added item.

To throw a wrench into the mix, at any time, a background thread can update the value of an item. I've faked that here in the 'Add' button's action with a Task to show what I mean. In this case, I'd love it to fade to it's new size, and again, scroll to the bottom. (I have it scrolling, but that's because the other animations are disabled.)

Also, is there a way to make ItemListView be able to respond to changes of the Item objects it's managing? The only way I found to do this was to set up the ItemView to listen to its owned message's change notifications, then forward that change back to ItemListView via a closure, but this is the coding equivalent of nails on a chalkboard to me! Is there an easier way?

Things I've tried:

  1. Using transactions to disable all animations but the one I'm doing with scrolling
  2. Calling DispatchQueue.main.async around all withAnimation calls wondering if it may be because the changes were coming in on a background thread
  3. Wrapping all add and update calls in async MainActor.run from all the places I may be updating the model from a background thread
  4. Stacking animations
  5. Looking for notifications of when any existing/outstanding animations are complete.

Again, the only 100% fool-proof way to get the scrolling to always work as expected is to disable all add/remove/update animations. If I do that, I can animate the scrolling itself just fine. It just looks janky.

Been fighting this for two days now. Any chance someone can take a stab at it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment