Skip to content

Commit fbcc56f

Browse files
committed
✨ Implement focus management
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>
1 parent 49a8d9f commit fbcc56f

File tree

4 files changed

+114
-6
lines changed

4 files changed

+114
-6
lines changed

code/frontend/MakeItSo/MakeItSo.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

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

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

5355
/* Begin PBXGroup section */
56+
881EF5BB272DC366004761E5 /* Extensions */ = {
57+
isa = PBXGroup;
58+
children = (
59+
881EF5BC272DC399004761E5 /* View+Focus.swift */,
60+
);
61+
path = Extensions;
62+
sourceTree = "<group>";
63+
};
5464
88FEECC427275ABC00ED368C = {
5565
isa = PBXGroup;
5666
children = (
@@ -63,6 +73,7 @@
6373
88FEECC927275ABC00ED368C /* Shared */ = {
6474
isa = PBXGroup;
6575
children = (
76+
881EF5BB272DC366004761E5 /* Extensions */,
6677
88FEECEB2727FEC100ED368C /* Features */,
6778
88FEECCA27275ABC00ED368C /* MakeItSoApp.swift */,
6879
88FEECCC27275ABD00ED368C /* Assets.xcassets */,
@@ -231,6 +242,7 @@
231242
88FEECF02727FEFF00ED368C /* Task.swift in Sources */,
232243
88FEECFA27280F3D00ED368C /* TaskListRowView.swift in Sources */,
233244
88FEECDA27275ABD00ED368C /* MakeItSoApp.swift in Sources */,
245+
881EF5BD272DC399004761E5 /* View+Focus.swift in Sources */,
234246
88FEECF32728044100ED368C /* TasksListView.swift in Sources */,
235247
88FEECF72728072D00ED368C /* TasksListViewModel.swift in Sources */,
236248
);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// View+Focus.swift
3+
// MakeItSo (iOS)
4+
//
5+
// Created by Peter Friese on 30.10.21.
6+
// Copyright © 2021 Google LLC. All rights reserved.
7+
//
8+
// Licensed under the Apache License, Version 2.0 (the "License");
9+
// you may not use this file except in compliance with the License.
10+
// You may obtain a copy of the License at
11+
//
12+
// http://www.apache.org/licenses/LICENSE-2.0
13+
//
14+
// Unless required by applicable law or agreed to in writing, software
15+
// distributed under the License is distributed on an "AS IS" BASIS,
16+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
// See the License for the specific language governing permissions and
18+
// limitations under the License.
19+
20+
import SwiftUI
21+
22+
/// Used to manage focus in a `List` view
23+
enum Focusable: Hashable {
24+
case row(id: String)
25+
}
26+
27+
extension View {
28+
/// Mirror changes between an @Published variable (typically in your View Model) and
29+
/// an @FocusedState variable in a view
30+
func sync<T: Equatable>(_ field1: Binding<T>, _ field2: FocusState<T>.Binding ) -> some View {
31+
self
32+
.onChange(of: field1.wrappedValue) { field2.wrappedValue = $0 }
33+
.onChange(of: field2.wrappedValue) { field1.wrappedValue = $0 }
34+
}
35+
}

code/frontend/MakeItSo/Shared/Features/TaskList/ViewModels/TasksListViewModel.swift

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,74 @@ import SwiftUI
2323

2424
class TasksListViewModel: ObservableObject {
2525
@Published var tasks: [Task]
26+
@Published var focusedTask: Focusable?
27+
var previousFocusedTask: Focusable?
2628

2729
private var cancellables = Set<AnyCancellable>()
2830

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

32-
$tasks.sink { newValue in
33-
self.dump(newValue)
34-
self.performUpdates(newValue)
35-
}
36-
.store(in: &cancellables)
34+
// This is the beginning of some magic Firestore sauce
35+
// $tasks.sink { newValue in
36+
// self.dump(newValue)
37+
// self.performUpdates(newValue)
38+
// }
39+
// .store(in: &cancellables)
40+
41+
// the following pipeline removes empty tasks when the respecive row in the list view loses focus
42+
$focusedTask
43+
.removeDuplicates()
44+
.compactMap { focusedTask -> Int? in
45+
defer { self.previousFocusedTask = focusedTask }
46+
47+
guard focusedTask != nil else { return nil }
48+
guard case .row(let previousId) = self.previousFocusedTask else { return nil }
49+
guard let previousIndex = self.tasks.firstIndex(where: { $0.id == previousId } ) else { return nil }
50+
guard self.tasks[previousIndex].title.isEmpty else { return nil }
51+
52+
return previousIndex
53+
}
54+
.delay(for: 0.01, scheduler: RunLoop.main) // <-- this helps reduce the visual jank
55+
.sink { index in
56+
self.tasks.remove(at: index)
57+
}
58+
.store(in: &cancellables)
59+
60+
// This is the unoptimised version. It results in visual jank.
61+
// $focusedTask
62+
// .removeDuplicates()
63+
// .sink { focusedTask in
64+
// if case .row(let previousId) = self.previousFocusedTask, case .row(let currentId) = focusedTask {
65+
// print("Previous: \(previousId)")
66+
// print("Current: \(currentId)")
67+
// if let previousIndex = self.tasks.firstIndex(where: { $0.id == previousId } ) {
68+
// if self.tasks[previousIndex].title.isEmpty {
69+
// self.tasks.remove(at: previousIndex)
70+
// }
71+
// }
72+
// }
73+
// self.previousFocusedTask = focusedTask
74+
// }
75+
// .store(in: &cancellables)
3776
}
3877

3978
func createNewTask() {
40-
self.tasks.append(Task(title: ""))
79+
let newTask = Task(title: "")
80+
81+
// if any row is focused, insert the new task after the focused row
82+
if case .row(let id) = focusedTask {
83+
if let index = tasks.firstIndex(where: { $0.id == id } ) {
84+
tasks.insert(newTask, at: index + 1)
85+
}
86+
}
87+
// no row focused: append at the end of the list
88+
else {
89+
tasks.append(newTask)
90+
}
91+
92+
// focus the new task
93+
focusedTask = .row(id: newTask.id)
4194
}
4295

4396
func deleteTask(_ task: Task) {

code/frontend/MakeItSo/Shared/Features/TaskList/Views/TasksListView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ struct TasksListView: View {
2323
@EnvironmentObject
2424
var viewModel: TasksListViewModel
2525

26+
@FocusState
27+
var focusedTask: Focusable?
28+
2629
init() {
2730
// Turn this into a view modifier. See [Navigation Bar Styling in SwiftUI - YouTube](https://youtu.be/kCJyhG8zjvY)
2831
let navBarAppearance = UINavigationBarAppearance()
@@ -34,6 +37,10 @@ struct TasksListView: View {
3437
List {
3538
ForEach($viewModel.tasks) { $task in
3639
TaskListRowView(task: $task)
40+
.focused($focusedTask, equals: .row(id: task.id))
41+
.onSubmit {
42+
viewModel.createNewTask()
43+
}
3744
.swipeActions {
3845
Button(role: .destructive, action: { viewModel.deleteTask(task) }) {
3946
Label("Delete", systemImage: "trash")
@@ -49,6 +56,7 @@ struct TasksListView: View {
4956
}
5057
}
5158
}
59+
.sync($viewModel.focusedTask, $focusedTask)
5260
.animation(.default, value: viewModel.tasks)
5361
.listStyle(.plain)
5462
.navigationTitle("Tasks")

0 commit comments

Comments
 (0)