listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

commit 0fe7724146bf1c0b88d19f859e5013e74a946f6c
parent 0f2bb8036689bb67561b40f272818eb5fdfea0ed
Author: Michael Camilleri <[email protected]>
Date:   Tue, 31 Mar 2026 01:23:32 +0900

Improve draft row adds and removes

This commit adds more transitions for the draft row that appears in the
iOS version when the user 'creates' a row (be that using the
pull-to-create gesture or the tap-to-create gesture).

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MListless/Extensions/ItemListView+Logic.swift | 12++++--------
MListless/Helpers/ItemListViewProtocol.swift | 1+
MListlessMac/Views/ItemListView.swift | 8++++++++
MListlessiOS/Views/ItemListView.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++----------------
MListlessiOS/Views/UndoToast.swift | 1+
5 files changed, 61 insertions(+), 24 deletions(-)

diff --git a/Listless/Extensions/ItemListView+Logic.swift b/Listless/Extensions/ItemListView+Logic.swift @@ -86,21 +86,17 @@ extension ItemListViewProtocol { } func createNewItem() { - revealDraftItem(at: .append) + revealDraftItem(at: .append, animated: true) } - func revealDraftItem(at placement: DraftItemPlacement) { + func revealDraftItem(at placement: DraftItemPlacement, animated: Bool = false) { if draftPlacement != placement, draftPlacement != nil { commitDraftItem() } clearDragState() - let itemID = draftID(for: placement) draftTitle = "" - draftPlacement = placement - fState.pendingFocus = .item(itemID) - focusedField = .item(itemID) - fState.selectedItemID = itemID + revealDraftItemUI(at: placement, animated: animated) } func beginDraftItemEditing(_ placement: DraftItemPlacement) { @@ -199,7 +195,7 @@ extension ItemListViewProtocol { fState.selectedItemID = nil focusedField = nil } else { - revealDraftItem(at: .append) + revealDraftItem(at: .append, animated: true) } } diff --git a/Listless/Helpers/ItemListViewProtocol.swift b/Listless/Helpers/ItemListViewProtocol.swift @@ -13,5 +13,6 @@ protocol ItemListViewProtocol { var draftPlacement: DraftItemPlacement? { get nonmutating set } var draftTitle: String { get nonmutating set } func didStartDrag() + func revealDraftItemUI(at placement: DraftItemPlacement, animated: Bool) func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool) } diff --git a/ListlessMac/Views/ItemListView.swift b/ListlessMac/Views/ItemListView.swift @@ -166,6 +166,14 @@ struct ItemListView: View, ItemListViewProtocol { iState.liftedItemID == itemID || draggedItemID == itemID } + func revealDraftItemUI(at placement: DraftItemPlacement, animated: Bool = false) { + let itemID = draftID(for: placement) + draftPlacement = placement + fState.pendingFocus = .item(itemID) + focusedField = .item(itemID) + fState.selectedItemID = itemID + } + func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle _: Bool) { if draftPlacement == placement { draftPlacement = nil diff --git a/ListlessiOS/Views/ItemListView.swift b/ListlessiOS/Views/ItemListView.swift @@ -163,6 +163,22 @@ struct ItemListView: View, ItemListViewProtocol { self.onFinishTutorial = onFinishTutorial } + func revealDraftItemUI(at placement: DraftItemPlacement, animated: Bool) { + let itemID = draftID(for: placement) + if animated { + withAnimation { draftPlacement = placement } + } else { + draftPlacement = placement + } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + fState.pendingFocus = .item(itemID) + focusedField = .item(itemID) + fState.selectedItemID = itemID + } + } + func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool) { let clear: () -> Void = { if draftPlacement == placement { @@ -191,6 +207,8 @@ struct ItemListView: View, ItemListViewProtocol { withTransaction(transaction) { clear() } + } else if !hasTitle { + withAnimation { clear() } } else { clear() } @@ -240,7 +258,13 @@ struct ItemListView: View, ItemListViewProtocol { let frameHeight: CGFloat = isPrependDraftVisible ? 0 : min(pullOffset, indicatorHeight + rowGap) - let opacity: Double = isPrependDraftVisible || pullOffset <= 0 ? 0 : 1 + let opacity: Double = if isPrependDraftVisible || pullOffset <= 0 { + 0 + } else if displayActiveItems.isEmpty { + min(1, pullOffset / indicatorHeight) + } else { + 1 + } PullToCreateIndicator( pullOffset: max(0, indicatorDisplayOffset), threshold: pullCreateThreshold @@ -249,6 +273,11 @@ struct ItemListView: View, ItemListViewProtocol { height: frameHeight, alignment: .top ) + .offset( + y: displayActiveItems.isEmpty + ? -indicatorHeight * (1 - min(1, pullOffset / indicatorHeight)) + : 0 + ) .opacity(opacity) } @@ -371,6 +400,23 @@ struct ItemListView: View, ItemListViewProtocol { } draftAppendRow + .zIndex(3) + .overlay(alignment: .top) { + if showTutorialHint { + Text("Submit empty to remove") + .font(.body) + .foregroundStyle(.black) + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(red: 1.0, green: 0.84, blue: 0.04)) + ) + .shadow(color: .black.opacity(0.15), radius: 8, y: 2) + .offset(y: -56) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } ForEach(completedItems) { item in let itemID = item.id @@ -450,21 +496,6 @@ struct ItemListView: View, ItemListViewProtocol { guard !Task.isCancelled else { return } dismissUndoToast() } - .overlay(alignment: .top) { - if showTutorialHint { - Text("Submit empty to remove") - .font(.body) - .foregroundStyle(.black) - .padding(.horizontal, 20) - .padding(.vertical, 14) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(red: 1.0, green: 0.84, blue: 0.04)) - ) - .padding(.top, 24) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } .onChange(of: iState.draftPlacement) { _, newValue in if isTutorial, newValue == .append { withAnimation { diff --git a/ListlessiOS/Views/UndoToast.swift b/ListlessiOS/Views/UndoToast.swift @@ -27,6 +27,7 @@ struct UndoToastView: View { RoundedRectangle(cornerRadius: 12) .fill(Color(white: 0.2)) ) + .shadow(color: .black.opacity(0.15), radius: 8, y: 2) .padding(.bottom, 24) .transition(.move(edge: .bottom).combined(with: .opacity)) }