Last active
May 30, 2023 21:56
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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...
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:
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?