Skip to content

Instantly share code, notes, and snippets.

@disc0infern0
Created November 4, 2021 12:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save disc0infern0/67da1a717e832d4d8b2d272b6986beb1 to your computer and use it in GitHub Desktop.
Save disc0infern0/67da1a717e832d4d8b2d272b6986beb1 to your computer and use it in GitHub Desktop.
a Reminder List with control of focus, adding new values with double click, auto deleting nil values, using Combine for delay
//
// ReminderList.swift
//
// Demonstrates:-
// list settings (style, colouring etc )
// autoscrolling
// control of focus in List,
// adding new values with enter, or double click,
// auto deleting nil values,
// using Combine for managed delay/UI updates
//
// Created by Andrew Cowley on 29/10/2021.
//
import SwiftUI
import Combine
struct ReminderList: View {
@EnvironmentObject
var vm : ReminderListVM
@FocusState
private var focusedField: RowID?
var body: some View {
ScrollViewReader { proxy in
List() {
ForEach($vm.reminders) { $reminder in
ReminderRow(reminder: $reminder)
.listRowBackground ( Color.blue )
.listRowInsets(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
.swipeActions() {
Button(role: .destructive) {vm.deleteReminder(reminder)}
label: { Label("Delete", systemImage: "trash" ) }
}
.focused($focusedField, equals: .row(id: reminder.id))
.onSubmit { vm.submit(reminder) }
.onTapGesture { vm.tap(reminder: reminder) }
}.background(Color.teal)
}
.onChange(of: vm.reminders) {_ in proxy.scrollTo(vm.focusedID, anchor: .bottom) } //keep new entries visible
.listStyle(.plain)
.background(Color.indigo)
.animation(.easeInOut(duration: 0.6), value: vm.reminders )
.sync($vm.focusedField, $focusedField)
// a tap on a valid row will first fire the background tap below, then the tap on the reminder
.gesture( TapGesture(count: 1) .onEnded( vm.tap ) )
.highPriorityGesture( TapGesture(count: 2) .onEnded( vm.doubleTap ) )
}
.background( Color.secondary )
.environment(\.defaultMinListRowHeight, 10)
} // body
}
enum RowID : Hashable { case row(id: String) }
class ReminderListVM : ObservableObject{
@Published var reminders: [Reminder] = Reminder.examples
@Published var focusedField: RowID? = nil
@Published var delayedFocusedField: RowID? = nil
private var cancellables = Set<AnyCancellable>()
var previousFocusedField: RowID? // intermediate value that adds delay
var focusedID: String { rowid(focusedField) }
// Helper function for debugging/printing FocusState fields
func rowid(_ row: RowID?) -> String {
guard case.row(let id) = row else { return "" }
return id
}
init() {
// push the values to focusedField after a small delay
$delayedFocusedField
.removeDuplicates()
.delay(for: 0.01, scheduler: RunLoop.current )
.assign(to: &$focusedField)
// Remove last focused entry if it is empty
$focusedField
.removeDuplicates()
.receive(on: RunLoop.main)
.compactMap { focusedField -> Int? in
defer { self.previousFocusedField = focusedField }
guard
self.previousFocusedField != nil,
case .row(let previousId) = self.previousFocusedField,
let previousIndex = self.reminders.firstIndex(where: { $0.id == previousId } ),
self.reminders[previousIndex].text.isEmpty
else { return nil }
return previousIndex
}
.sink { self.reminders.remove(at: $0) }
.store(in: &cancellables)
}
func submit(_ reminder: Reminder) {
if reminder.text.count > 0 {
newReminder(after: reminder)
} else { // put cursor back on the reminder ( onSubmit cancels focus )
setFocus(reminder)
}
}
/// for a tap anywhere on the List, including blank areas
func tap() {
// Important! Do not delay this action ,since we may need to override it
// on the reminder row. Setting the same delay causes indeterminate results
focusedField = nil
}
/// for a tap on a valid reminder list row
func tap(reminder: Reminder) {
focusedField = .row(id: reminder.id) // override background tap that sets nil focus
}
func doubleTap() {
if let lastReminder = reminders.last {
if lastReminder.text.count > 0 {
newReminder(after: lastReminder)
}
else {
focusedField = .row(id: lastReminder.id)
}
}
}
func newReminder(after reminder: Reminder) {
let newReminder = Reminder()
if let index = reminders.firstIndex(where: {$0.id == reminder.id }) {
reminders.insert(newReminder,at: index+1)
} else {
reminders.append(newReminder)
}
setFocus(newReminder)
}
func deleteReminder(_ reminder: Reminder) {
Just(reminder)
.delay(for: .seconds(0.25), scheduler: RunLoop.main)
.sink { reminder in
self.reminders.removeAll { $0.id == reminder.id }
}
.store(in: &cancellables)
}
func setFocus(_ reminder: Reminder) {
delayedFocusedField = .row(id: reminder.id)
}
}
struct Reminder: Identifiable, Equatable {
var id = UUID().uuidString
var text: String = ""
static var examples = [ Reminder(id: "one",text: "one"), Reminder(id: "two",text: "two"), Reminder(id: "three", text: "three")]
}
struct ReminderRow: View {
@Binding var reminder: Reminder
var body: some View {
HStack{
TextField("Reminder name", text: $reminder.text )
.accentColor(.primary) // cursor color
.disableAutocorrection(true)
}
}
}
extension View {
/// Mirror changes between an @Published variable (typically in your View Model) and an @FocusedState variable in a view
func sync<T: Equatable>(_ field1: Binding<T>, _ field2: FocusState<T>.Binding ) -> some View {
return self
.onChange(of: field1.wrappedValue) { field2.wrappedValue = $0 }
.onChange(of: field2.wrappedValue) { field1.wrappedValue = $0 }
}
}
struct ReminderList_Previews: PreviewProvider {
static var previews: some View {
ReminderList().environmentObject(ReminderListVM())
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment