listless

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

ItemRowView.swift (9418B)


      1 import SwiftUI
      2 
      3 struct ItemRowView: View {
      4     let item: ItemValue
      5     let itemID: UUID
      6     let index: Int
      7     let totalItems: Int
      8     let isSelected: Bool
      9     @Binding var isDragging: Bool
     10     @Binding var isSwiping: Bool
     11     let onToggle: (UUID) -> Void
     12     let onTitleChange: (UUID, String) -> Void
     13     let onDelete: (UUID) -> Void
     14     let onSelect: (UUID) -> Void
     15     let isLastActiveItem: Bool
     16     let onStartEdit: (UUID) -> Void
     17     let onEndEdit: (UUID, _ shouldCreateNewItem: Bool) -> Void
     18     @FocusState.Binding var focusedField: FocusField?
     19 
     20     @AppStorage("colorTheme") private var colorThemeRaw = 0
     21     private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
     22     @State private var swipeOffset: CGFloat = 0
     23     @State private var swipeDirection: ItemRowSwipeGesture.SwipeDirection = .none
     24     @State private var isSwipeTriggered: Bool = false
     25     @State private var editingTitle: String = ""
     26     @State private var isCurrentlyEditing: Bool = false
     27     @State private var tapPoint: CGPoint? = nil
     28     @State private var cachedAccentColor: Color = .clear
     29 
     30     init(
     31         item: ItemValue,
     32         itemID: UUID,
     33         index: Int = 0,
     34         totalItems: Int = 1,
     35         isSelected: Bool,
     36         isDragging: Binding<Bool> = .constant(false),
     37         isSwiping: Binding<Bool> = .constant(false),
     38         isLastActiveItem: Bool = false,
     39         focusedField: FocusState<FocusField?>.Binding,
     40         onToggle: @escaping (UUID) -> Void,
     41         onTitleChange: @escaping (UUID, String) -> Void,
     42         onDelete: @escaping (UUID) -> Void,
     43         onSelect: @escaping (UUID) -> Void,
     44         onStartEdit: @escaping (UUID) -> Void = { _ in },
     45         onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in }
     46     ) {
     47         self.item = item
     48         self.itemID = itemID
     49         self.index = index
     50         self.totalItems = totalItems
     51         self.isSelected = isSelected
     52         _isDragging = isDragging
     53         _isSwiping = isSwiping
     54         self.isLastActiveItem = isLastActiveItem
     55         self.onToggle = onToggle
     56         self.onTitleChange = onTitleChange
     57         self.onDelete = onDelete
     58         self.onSelect = onSelect
     59         self.onStartEdit = onStartEdit
     60         self.onEndEdit = onEndEdit
     61         _focusedField = focusedField
     62     }
     63 
     64     var body: some View {
     65         HStack(alignment: .center, spacing: ItemRowMetrics.contentSpacing) {
     66             Button {
     67                 onToggle(itemID)
     68             } label: {
     69                 // When a right-swipe is past the threshold, preview the toggled state
     70                 let previewCompleted = isSwipeTriggered && swipeDirection == .right
     71                     ? !item.isCompleted
     72                     : item.isCompleted
     73                 Image(systemName: previewCompleted ? "checkmark.circle.fill" : "circle")
     74                     .contentTransition(.identity)
     75                     .frame(width: 22, height: 22)
     76                     .foregroundStyle(Color.secondary)
     77                     .font(.system(size: 17))
     78             }
     79             .buttonStyle(.borderless)
     80             .accessibilityIdentifier("item-checkbox")
     81             .accessibilityValue(item.isCompleted ? "checkmark.circle.fill" : "circle")
     82 
     83             if !item.isCompleted && (isSelected || isEditing) {
     84                 TappableTextField(
     85                     text: $editingTitle,
     86                     isCompleted: item.isCompleted,
     87                     isDragging: isDragging,
     88                     onEditingChanged: { editing, shouldCreateNewItem in
     89                         DispatchQueue.main.async {
     90                             isCurrentlyEditing = editing
     91                             if editing { onStartEdit(itemID) }
     92                             else {
     93                                 tapPoint = nil
     94                                 onEndEdit(itemID, shouldCreateNewItem)
     95                             }
     96                         }
     97                     },
     98                     returnKeyType: isLastActiveItem && !editingTitle.isEmpty ? .next : .done,
     99                     onContentChange: { newTitle in
    100                         guard !item.isCompleted else { return }
    101                         onTitleChange(itemID, newTitle)
    102                     },
    103                     uiAccessibilityIdentifier: "item-text-\(itemID.uuidString)",
    104                     initialCursorPoint: tapPoint
    105                 )
    106                 .focused($focusedField, equals: .item(itemID))
    107                 .frame(maxWidth: .infinity, alignment: .leading)
    108             } else if !item.isCompleted {
    109                 itemProxy
    110                     .frame(maxWidth: .infinity, alignment: .leading)
    111                     .contentShape(Rectangle())
    112                     .gesture(SpatialTapGesture().onEnded { value in
    113                         tapPoint = value.location
    114                         onSelect(itemID)
    115                         focusedField = .item(itemID)
    116                     })
    117             } else {
    118                 itemProxy
    119                     .frame(maxWidth: .infinity, alignment: .leading)
    120             }
    121         }
    122         .padding(.vertical, ItemRowMetrics.contentVerticalPadding)
    123         .padding(.trailing, ItemRowMetrics.contentHorizontalPadding)
    124         .padding(
    125             .leading,
    126             item.isCompleted ? ItemRowMetrics.completedLeadingPadding : ItemRowMetrics.activeLeadingPadding
    127         )
    128         .frame(maxWidth: .infinity, alignment: .leading)
    129         .contentShape(Rectangle())
    130         .onTapGesture {
    131             // .onTapGesture (not .simultaneousGesture) lets the child Button suppress this
    132             // gesture for its own hit area, so circle button taps don't also fire here.
    133             // If tapping a completed row while another row is being edited, preserve
    134             // the current focus/selection.
    135             if item.isCompleted,
    136                let field = focusedField,
    137                case .item(let id) = field,
    138                id != itemID
    139             {
    140                 return
    141             }
    142             if item.isCompleted {
    143                 withAnimation { onToggle(itemID) }
    144             } else {
    145                 tapPoint = nil
    146                 onSelect(itemID)
    147                 focusedField = .item(itemID)
    148             }
    149         }
    150         .background(cardBackground)
    151         .overlay(alignment: .leading) {
    152             if !item.isCompleted {
    153                 Rectangle()
    154                     .fill(cachedAccentColor)
    155                     .frame(width: ItemRowMetrics.accentBarWidth)
    156             }
    157         }
    158         .onAppear {
    159             editingTitle = item.title
    160             cachedAccentColor = computeAccentColor()
    161             if index == 0, !item.isCompleted,
    162                 ProcessInfo.processInfo.arguments.contains("SCREENSHOT_SWIPE")
    163             {
    164                 swipeOffset = 60
    165                 swipeDirection = .right
    166                 isSwipeTriggered = true
    167             }
    168         }
    169         .onChange(of: item.title) { _, newValue in
    170             if !isCurrentlyEditing {
    171                 editingTitle = newValue
    172             }
    173         }
    174         .onChange(of: index) { _, _ in
    175             cachedAccentColor = computeAccentColor()
    176         }
    177         .onChange(of: totalItems) { _, _ in
    178             cachedAccentColor = computeAccentColor()
    179         }
    180         .onChange(of: colorThemeRaw) { _, _ in
    181             cachedAccentColor = computeAccentColor()
    182         }
    183         .itemSwipeGesture(
    184             isDragging: $isDragging,
    185             isEditing: focusedField == .item(itemID),
    186             isSwiping: $isSwiping,
    187             swipeOffset: $swipeOffset,
    188             swipeDirection: $swipeDirection,
    189             isTriggered: $isSwipeTriggered,
    190             completeColor: cachedAccentColor,
    191             onComplete: { onToggle(itemID) },
    192             onDelete: { onDelete(itemID) }
    193         )
    194         .onChange(of: isDragging) { _, newValue in
    195             if newValue {
    196                 swipeOffset = 0
    197                 swipeDirection = .none
    198                 isSwipeTriggered = false
    199             }
    200         }
    201         .clipShape(ItemCardModifier.shape)
    202         .overlay(
    203             isSelected && !item.isCompleted
    204                 ? ItemCardModifier.shape
    205                     .strokeBorder(cachedAccentColor.opacity(0.40), lineWidth: 2)
    206                 : nil
    207         )
    208     }
    209 
    210     private var isEditing: Bool {
    211         focusedField == .item(itemID)
    212     }
    213 
    214     @ViewBuilder
    215     private var itemProxy: some View {
    216         if item.isCompleted {
    217             Text(editingTitle)
    218                 .font(ItemRowMetrics.bodySUI)
    219                 .foregroundStyle(.secondary)
    220                 .strikethrough(true, color: .secondary)
    221                 .accessibilityIdentifier("item-text-\(itemID.uuidString)")
    222         } else {
    223             Text(editingTitle)
    224                 .font(ItemRowMetrics.bodySUI)
    225                 .foregroundStyle(.primary)
    226                 .accessibilityIdentifier("item-text-\(itemID.uuidString)")
    227         }
    228     }
    229 
    230     @MainActor
    231     private func computeAccentColor() -> Color {
    232         guard !item.isCompleted else { return .clear }
    233         return cachedItemColor(forIndex: index, total: totalItems, theme: colorTheme)
    234     }
    235 
    236     @ViewBuilder
    237     private var cardBackground: some View {
    238         if item.isCompleted {
    239             isSelected ? Color.completedSelected : Color.clear
    240         } else if isSelected {
    241             Color.itemCard.overlay(cachedAccentColor.opacity(0.15))
    242         } else {
    243             Color.itemCard
    244         }
    245     }
    246 }