← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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