Skip to content

Instantly share code, notes, and snippets.

@peterfriese
Forked from disc0infern0/ReminderList
Last active November 9, 2021 08:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save peterfriese/36d3f59527e33224d09a34da481142bc to your computer and use it in GitHub Desktop.
Save peterfriese/36d3f59527e33224d09a34da481142bc 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)
}
}
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(text: "one"),
Reminder(text: "two"),
Reminder(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