Skip to content

Commit

Permalink
✨ Implement focus management
Browse files Browse the repository at this point in the history
This implements the following:

- Tapping [enter] will insert a new task after the currently selected task
- Tapping the "New reminder" button will insert a new task after the currently selected task
- The newly added tasks will be focused (i.e. the cursor will be placed in the TextField)
- If an empty task loses focus, it will be removed

This resolves #63

Signed-off-by: Peter Friese <peter@peterfriese.de>
  • Loading branch information
peterfriese committed Oct 30, 2021
1 parent 49a8d9f commit fbcc56f
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 6 deletions.
12 changes: 12 additions & 0 deletions code/frontend/MakeItSo/MakeItSo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
881EF5BD272DC399004761E5 /* View+Focus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 881EF5BC272DC399004761E5 /* View+Focus.swift */; };
88FEECDA27275ABD00ED368C /* MakeItSoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88FEECCA27275ABC00ED368C /* MakeItSoApp.swift */; };
88FEECDB27275ABD00ED368C /* MakeItSoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88FEECCA27275ABC00ED368C /* MakeItSoApp.swift */; };
88FEECDE27275ABD00ED368C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88FEECCC27275ABD00ED368C /* Assets.xcassets */; };
Expand All @@ -22,6 +23,7 @@
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
881EF5BC272DC399004761E5 /* View+Focus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Focus.swift"; sourceTree = "<group>"; };
88FEECCA27275ABC00ED368C /* MakeItSoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeItSoApp.swift; sourceTree = "<group>"; };
88FEECCC27275ABD00ED368C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
88FEECD127275ABD00ED368C /* MakeItSo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MakeItSo.app; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -51,6 +53,14 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
881EF5BB272DC366004761E5 /* Extensions */ = {
isa = PBXGroup;
children = (
881EF5BC272DC399004761E5 /* View+Focus.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
88FEECC427275ABC00ED368C = {
isa = PBXGroup;
children = (
Expand All @@ -63,6 +73,7 @@
88FEECC927275ABC00ED368C /* Shared */ = {
isa = PBXGroup;
children = (
881EF5BB272DC366004761E5 /* Extensions */,
88FEECEB2727FEC100ED368C /* Features */,
88FEECCA27275ABC00ED368C /* MakeItSoApp.swift */,
88FEECCC27275ABD00ED368C /* Assets.xcassets */,
Expand Down Expand Up @@ -231,6 +242,7 @@
88FEECF02727FEFF00ED368C /* Task.swift in Sources */,
88FEECFA27280F3D00ED368C /* TaskListRowView.swift in Sources */,
88FEECDA27275ABD00ED368C /* MakeItSoApp.swift in Sources */,
881EF5BD272DC399004761E5 /* View+Focus.swift in Sources */,
88FEECF32728044100ED368C /* TasksListView.swift in Sources */,
88FEECF72728072D00ED368C /* TasksListViewModel.swift in Sources */,
);
Expand Down
35 changes: 35 additions & 0 deletions code/frontend/MakeItSo/Shared/Extensions/View+Focus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// View+Focus.swift
// MakeItSo (iOS)
//
// Created by Peter Friese on 30.10.21.
// Copyright © 2021 Google LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import SwiftUI

/// Used to manage focus in a `List` view
enum Focusable: Hashable {
case row(id: String)
}

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 {
self
.onChange(of: field1.wrappedValue) { field2.wrappedValue = $0 }
.onChange(of: field2.wrappedValue) { field1.wrappedValue = $0 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,74 @@ import SwiftUI

class TasksListViewModel: ObservableObject {
@Published var tasks: [Task]
@Published var focusedTask: Focusable?
var previousFocusedTask: Focusable?

private var cancellables = Set<AnyCancellable>()

init(tasks: [Task]) {
self.tasks = tasks

$tasks.sink { newValue in
self.dump(newValue)
self.performUpdates(newValue)
}
.store(in: &cancellables)
// This is the beginning of some magic Firestore sauce
// $tasks.sink { newValue in
// self.dump(newValue)
// self.performUpdates(newValue)
// }
// .store(in: &cancellables)

// the following pipeline removes empty tasks when the respecive row in the list view loses focus
$focusedTask
.removeDuplicates()
.compactMap { focusedTask -> Int? in
defer { self.previousFocusedTask = focusedTask }

guard focusedTask != nil else { return nil }
guard case .row(let previousId) = self.previousFocusedTask else { return nil }
guard let previousIndex = self.tasks.firstIndex(where: { $0.id == previousId } ) else { return nil }
guard self.tasks[previousIndex].title.isEmpty else { return nil }

return previousIndex
}
.delay(for: 0.01, scheduler: RunLoop.main) // <-- this helps reduce the visual jank
.sink { index in
self.tasks.remove(at: index)
}
.store(in: &cancellables)

// This is the unoptimised version. It results in visual jank.
// $focusedTask
// .removeDuplicates()
// .sink { focusedTask in
// if case .row(let previousId) = self.previousFocusedTask, case .row(let currentId) = focusedTask {
// print("Previous: \(previousId)")
// print("Current: \(currentId)")
// if let previousIndex = self.tasks.firstIndex(where: { $0.id == previousId } ) {
// if self.tasks[previousIndex].title.isEmpty {
// self.tasks.remove(at: previousIndex)
// }
// }
// }
// self.previousFocusedTask = focusedTask
// }
// .store(in: &cancellables)
}

func createNewTask() {
self.tasks.append(Task(title: ""))
let newTask = Task(title: "")

// if any row is focused, insert the new task after the focused row
if case .row(let id) = focusedTask {
if let index = tasks.firstIndex(where: { $0.id == id } ) {
tasks.insert(newTask, at: index + 1)
}
}
// no row focused: append at the end of the list
else {
tasks.append(newTask)
}

// focus the new task
focusedTask = .row(id: newTask.id)
}

func deleteTask(_ task: Task) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ struct TasksListView: View {
@EnvironmentObject
var viewModel: TasksListViewModel

@FocusState
var focusedTask: Focusable?

init() {
// Turn this into a view modifier. See [Navigation Bar Styling in SwiftUI - YouTube](https://youtu.be/kCJyhG8zjvY)
let navBarAppearance = UINavigationBarAppearance()
Expand All @@ -34,6 +37,10 @@ struct TasksListView: View {
List {
ForEach($viewModel.tasks) { $task in
TaskListRowView(task: $task)
.focused($focusedTask, equals: .row(id: task.id))
.onSubmit {
viewModel.createNewTask()
}
.swipeActions {
Button(role: .destructive, action: { viewModel.deleteTask(task) }) {
Label("Delete", systemImage: "trash")
Expand All @@ -49,6 +56,7 @@ struct TasksListView: View {
}
}
}
.sync($viewModel.focusedTask, $focusedTask)
.animation(.default, value: viewModel.tasks)
.listStyle(.plain)
.navigationTitle("Tasks")
Expand Down

0 comments on commit fbcc56f

Please sign in to comment.