gshaw / notes

Issues and solutions I find during software development.
https://gshaw.ca
MIT License
1 stars 0 forks source link

SwiftUI List bugs when moving rows into different sections. #2

Open gshaw opened 1 year ago

gshaw commented 1 year ago

I can't seem to figure out what SwiftUI needs to make animating rows into different sections work.

I'm trying to build a simple view that allows "pinning" items in a list.

I've built a simple app that attempts to implement this but it fails in various ways.

  1. Using .onTapGesture to toggle pin state animates the list well in all cases. Unfortunately this isn't the UX I want to use.
  2. Using .swipeActions causes glitches when the item is the first to be pinned. Once the pinned section is in the view tree pinning other rows works well. Unpinning works well until it will hide the section again.
  3. Using .contextMenu works when it is the first item to be pinned but glitches when pinning additional items (oppose of .swipeActions WTF?). This can be hacked by putting an artificial delay of around 500-700ms before toggling the state.
gshaw commented 1 year ago

Example program

import SwiftUI

@main
struct AppMain: App {
    var body: some Scene {
        WindowGroup {
            ItemsView()
        }
    }
}

private class Item: ObservableObject, Equatable {
    static func == (lhs: Item, rhs: Item) -> Bool {
        lhs.itemID == rhs.itemID && lhs.name == rhs.name && lhs.isPinned == rhs.isPinned
    }

    var itemID: UUID
    var name: String
    @Published var isPinned: Bool

    init() {
        itemID = UUID()
        name = "Item \(Int.random(in: 1000 ... 9999))"
        isPinned = false
    }
}

private class ItemsStore: ObservableObject, Equatable {
    static func == (lhs: ItemsStore, rhs: ItemsStore) -> Bool {
        lhs.items == rhs.items
    }

    @Published var items: [Item]

    init() {
        items = (1 ... 5).map { _ in Item() }
    }

    func createItem() {
        items.append(Item())
    }

    func deleteItem(item: Item) {
        items.removeAll(where: { $0.itemID == item.itemID })
    }

    func toggleItemPin(item: Item) {
        item.isPinned.toggle()
        objectWillChange.send()
    }
}

private struct ItemRowView: View {
    @ObservedObject var item: Item
    let togglePinAction: () -> Void
    let deleteAction: () -> Void

    var body: some View {
        HStack {
            Text(item.name)
        }
        .animation(.default, value: item.isPinned)
        .onTapGesture { togglePinAction() } // Works!
        .contextMenu {
            Button(action: delayedTogglePinAction) { pinLabel } // WTF? Gitches :( when moving to a NOT EMPTY section
            Button(role: .destructive, action: deleteAction) { Label("Delete", systemImage: "trash") } // Works!
        }
        .swipeActions(edge: .leading) { // WTF? Glitches :( when moving to an EMPTY section
            Button(action: togglePinAction) { pinLabel }
        }
        .swipeActions(edge: .trailing) {
            Button(role: .destructive, action: deleteAction) { Label("Delete", systemImage: "trash") } // Works!
        }
    }

    @ViewBuilder private var pinLabel: some View {
        item.isPinned ? Label("Unpin", systemImage: "pin.slash") : Label("Pin", systemImage: "pin")
    }

    private func delayedTogglePinAction() {
        // Hack to work aournd context menu glitch and moving row into non-empty section
        Task {
            try? await Task.sleep(nanoseconds: 500_000_000)
            togglePinAction()
        }
    }
}

/// Holds the items that should be visible on the view based on sorting, filtering, and grouping, i.e., search scope.
private struct ScopedItems: Equatable {
    var pinned: [Item] = []
    var items: [Item] = []

    init(items: [Item]) {
        let sortedItems = items.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending })
        pinned = sortedItems.filter(\.isPinned)
        self.items = sortedItems.filter { !$0.isPinned }
    }
}

struct ItemsView: View {
    @StateObject private var store = ItemsStore()

    var body: some View {
        NavigationStack {
            let scopedItems = ScopedItems(items: store.items)
            List {
                buildSection(title: "Pinned", items: scopedItems.pinned)
                buildSection(title: "Items", items: scopedItems.items)
            }
            .animation(.default, value: scopedItems)
            .navigationTitle("Items")
            .toolbar {
                ToolbarItemGroup(placement: .navigationBarTrailing) {
                    Button(
                        action: { store.createItem() },
                        label: { Label("New Item", systemImage: "plus") }
                    )
                }
            }
        }
    }

    @ViewBuilder private func buildSection(title: String, items: [Item]) -> some View {
        if !items.isEmpty {
            Section(header: Text(title)) {
                ForEach(items, id: \.itemID) { item in
                    ItemRowView(
                        item: item,
                        togglePinAction: { store.toggleItemPin(item: item) },
                        deleteAction: { store.deleteItem(item: item) }
                    )
                }
            }
        }
    }
}
gshaw commented 1 year ago

Screen recording of sample app.

https://user-images.githubusercontent.com/33321/214222630-0cf898d3-6773-4b28-af13-84cf43d4874d.mp4

gshaw commented 1 year ago

Environment is Xcode 14.2 iOS 16.2

gshaw commented 1 year ago

Reddit thread where I found the hack with adding a delay to .contextMenu to work around that bug. // https://www.reddit.com/r/iOSProgramming/comments/g2aepk/swiftui_list_context_menu_glitch/