Open gshaw opened 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) }
)
}
}
}
}
}
Screen recording of sample app.
https://user-images.githubusercontent.com/33321/214222630-0cf898d3-6773-4b28-af13-84cf43d4874d.mp4
Environment is Xcode 14.2 iOS 16.2
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/
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.
.onTapGesture
to toggle pin state animates the list well in all cases. Unfortunately this isn't the UX I want to use..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..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.