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:
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))
}