'SwiftUI' how to change checkmark in `listview` - ios

I'm trying to change the checkmark image on listview row selection, but I'm not getting expected result from selection.
Below code I'm creating listview from the array todo items and added tapGesture to the row. When I'm selecting row I can able to change the completed value but not reflecting that on UI.
struct ContentView: View {
#State var items: [Todo] = todoItems
var body: some View {
List(items, id: \.self) { item in
Row(item: item) { item in
item.completed.toggle()
print(item.completed)
}
}
}
}
struct Row: View {
let item: Todo
var onTap: ((_ item: Todo) -> Void)?
var body: some View {
HStack {
Image(systemName: item.completed ? "checkmark.square.fill" : "squareshape" )
.foregroundColor(item.completed ? .green : .gray)
Text(item.title).font(.headline).foregroundColor(.secondary)
}.onTapGesture {
onTap?(item)
}
}
}
class Todo: Hashable {
static func == (lhs: Todo, rhs: Todo) -> Bool {
lhs.title == rhs.title
}
var title: String
var completed: Bool
init(title: String, completed: Bool) {
self.title = title
self.completed = completed
}
func hash(into hasher: inout Hasher) {
hasher.combine(title)
}
}
let todoItems = [
Todo(title: "$300", completed: false),
Todo(title: "$550", completed: false),
Todo(title: "$450", completed: false)
]

You can make Todo a struct. For data like this, it is much more preferable for it to be a struct rather than a class.
The item also doesn't need to be passed down, since you already get it in the List. I also used the $ syntax within for $items, so $item is a Binding and you therefore can mutate item. Changing item is changing that element in #State variable items, therefore a view update occurs.
Your implementation for Equatable and Hashable on Todo was incorrect - because otherwise the view won't update if a Todo instance is the same, even with a different completed value. I added Identifiable to Todo anyway, so you can implicitly identify by id.
Code:
struct ContentView: View {
#State var items: [Todo] = todoItems
var body: some View {
List($items) { $item in
Row(item: item) {
item.completed.toggle()
}
}
}
}
struct Row: View {
let item: Todo
let onTap: (() -> Void)?
var body: some View {
HStack {
Image(systemName: item.completed ? "checkmark.square.fill" : "squareshape" )
.foregroundColor(item.completed ? .green : .gray)
Text(item.title).font(.headline).foregroundColor(.secondary)
}.onTapGesture {
onTap?()
}
}
}
struct Todo: Hashable, Identifiable {
let id = UUID()
var title: String
var completed: Bool
init(title: String, completed: Bool) {
self.title = title
self.completed = completed
}
}

Related

SwiftUI Slide Over Animation Like Builtin Navigation

I'm experimenting with replicating SwiftUI's navigation without all the black box magic. However, I'm having trouble with the animation. No animation happens until maybe the second or third push/pop. When it does finally animate, it's hard to describe what it does. But it definitely isn't what I would expect.
I've tried various different animations but it's generally the same behavior.
struct RouterDemo: View {
#State private var items: [Int] = Array(0..<50)
#State private var selectedItem: Int?
var body: some View {
RouterStore(
route: $selectedItem,
state: { route in items.first(where: { $0 == route }) },
content: { ItemsList(items: items, selectedItem: $0) },
destination: { route, item in
ItemDetail(item: item, selectedItem: route)
}
)
}
}
public struct RouterStore<Destination, Content, Route, DestinationState>: View
where Destination: View,
Content: View,
Route: Hashable,
DestinationState: Equatable {
#Binding private var route: Route?
private let toDestinationState: (Route) -> DestinationState?
private let destination: (Binding<Route?>, DestinationState) -> Destination
private let content: (Binding<Route?>) -> Content
public init(
route: Binding<Route?>,
state toDestinationState: #escaping (Route) -> DestinationState?,
#ViewBuilder content: #escaping (Binding<Route?>) -> Content,
#ViewBuilder destination: #escaping (Binding<Route?>, DestinationState) -> Destination
) {
self._route = route
self.toDestinationState = toDestinationState
self.destination = destination
self.content = content
}
public var body: some View {
GeometryReader { geometry in
ZStack {
content($route)
wrappedDestination()
.frame(width: geometry.size.width)
.offset(
x: route == nil ? geometry.size.width : 0,
y: 0
)
.animation(self.animation)
}
}
}
private var animation: Animation = .easeIn(duration: 2)
#ViewBuilder
private func wrappedDestination() -> some View {
if let _route = Binding($route),
let _destinationState = toDestinationState(_route.wrappedValue) {
ZStack {
Group {
if #available(iOS 15.0, *) {
Color(uiColor: UIColor.systemBackground)
} else {
Color(UIColor.systemBackground)
}
}
.preferredColorScheme(.light)
.ignoresSafeArea()
self.destination($route, _destinationState)
}
} else {
EmptyView()
}
}
}
struct ItemsList: View {
let items: [Int]
#Binding var selectedItem: Int?
var body: some View {
List {
ForEach(items, id: \.self) { item in
Button(
action: { selectedItem = item },
label: { Text(String(item)) }
)
.contentShape(Rectangle())
}
}
}
}
struct ItemDetail: View {
let item: Int
#Binding var selectedItem: Int?
var body: some View {
VStack {
Text(String(item))
Button(
action: { selectedItem = nil },
label: { Text("Back") }
)
}
}
}
Thanks to the links Asperi provided, I figured it out.
Applying the animation to the container and providing the value to monitor to the animation fixed it.

List Item with Picker (segmented control) subview and selection gesture

I have a List and items containing a Picker view (segmented control stye). I would like to handle selection and picker state separately. the picker should be disabled when list item is not selected.
The problem is:
first tap - selects the list item. (selected = true)
tap on picker - set selection = false
On UIKit, Button/SegmentControl/etc... catch the 'tap' and do not pass through to TableView selection state.
struct ListView: View {
#State var selected = Set<Int>()
let items = (1...10).map { ItemDataModel(id: $0) }
var body: some View {
List(items) { item in
ListItemView(dataModel: item)
.onTapGesture { if !(selected.remove(item.id) != .none) { selected.insert(item.id) }}
}
}
}
struct ListItemView: View {
#ObservedObject var dataModel: ItemDataModel
var body: some View {
let pickerBinding = Binding<Int>(
get: { dataModel.state?.rawValue ?? -1 },
set: { dataModel.state = DataState(rawValue: $0) }
)
HStack {
Text(dataModel.title)
Spacer()
Picker("sdf", selection: pickerBinding) {
Text("State 1").tag(0)
Text("State 2").tag(1)
Text("State 3").tag(2)
}.pickerStyle(SegmentedPickerStyle())
}
}
}
enum DataState: Int {
case state1, state2, state3
}
class ItemDataModel: Hashable, Identifiable, ObservableObject {
let id: Int
let title: String
#Published var state: DataState? = .state1
init(id: Int) {
self.id = id
title = "item \(id)"
}
func hash(into hasher: inout Hasher) {
hasher.combine(title)
}
static func == (lhs: ItemDataModel, rhs: ItemDataModel) -> Bool {
return lhs.id == rhs.id
}
}
[Please try to ignore syntax errors (if exsist) and focus on the gesture issue]
A possible solution is to apply modifiers only when the Picker is not selected:
struct ListView: View {
#State var selected = Set<Int>()
let items = (1 ... 10).map { ItemDataModel(id: $0) }
var body: some View {
List(items) { item in
if selected.contains(item.id) {
ListItemView(dataModel: item)
} else {
ListItemView(dataModel: item)
.disabled(true)
.onTapGesture {
if !(selected.remove(item.id) != .none) {
selected.insert(item.id)
}
}
}
}
}
}

SwifUI ForEach List keeps modified values when reloading a #Published array inside ObservableObject

I have a SwiftUI List populated using a ForEach loop binded to a #Published array inside an ObservableObject
Elements in the list can be modified with an on/off flag. When the list is cleared out all items seems removed from the UI but when the data is refetched and loaded inside the #Published array the UI still shows the state of te previously modified elements. As you can see in the following video:
The code to reproduce it:
struct ListView: View {
#ObservedObject private var viewModel = ListViewModel()
var body: some View {
VStack {
List {
ForEach(viewModel.list, id: \.self) { element in
ElementRow(element: element)
}
}
HStack {
Button(action: { viewModel.fetch() }) { Text("Refresh") }
Button(action: { viewModel.remove() }) { Text("Remove") }
}
}.onAppear {
viewModel.fetch()
}
}
}
struct ElementRow: View {
#ObservedObject var element: ElementViewModel
var body: some View {
HStack {
Text(element.id)
Spacer()
Toggle("", isOn: $element.on )
}
}
}
class ListViewModel : ObservableObject {
#Published var list: [ElementViewModel] = []
func fetch() {
list.append(contentsOf: [ElementViewModel(id: "0", on: false), ElementViewModel(id: "1", on: false)])
}
func remove() {
list.removeAll()
}
}
class ElementViewModel : ObservableObject, Hashable {
var id: String = ""
#Published var on: Bool = false
init(id: String, on: Bool) {
self.id = id
self.on = on
}
static func == (lhs: ElementViewModel, rhs: ElementViewModel) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
What needs to be changed to make that all the elements in the list remains off when it's refreshed?
Your model objects is interpreted as equal, so views are not updated (List caches rows similarly to UITableView).
Find below a fix. Tested with Xcode 12.4 / iOS 14.4
class ElementViewModel : ObservableObject, Hashable {
// ... other code
static func == (lhs: ElementViewModel, rhs: ElementViewModel) -> Bool {
lhs.id == rhs.id && lhs.on == rhs.on // << here !!
}
// ... other code
}

Filter #Published array in SwiftUI List removes elements in list

I am trying to implement a list functionality similar to to Handling User Input example, the interface shows a list the user can filter depending on boolean values. I want to add the following differences from the example:
Can edit list elements from the row itself
Move the filter logic to a ViewModel class
I've tried many approaches without success one of them:
ViewModel:
class TaskListViewModel : ObservableObject {
private var cancelables = Set<AnyCancellable>()
private var allTasks: [Task] =
[ Task(id: "1",name: "Task1", description: "Description", done: false),
Task(id: "2",name: "Task2", description: "Description", done: false)]
#Published var showNotDoneOnly = false
#Published var filterdTasks: [Task] = []
init() {
filterdTasks = allTasks
$showNotDoneOnly.map { notDoneOnly in
if notDoneOnly {
return self.filterdTasks.filter { task in
!task.done
}
}
return self.filterdTasks
}.assign(to: \.filterdTasks, on: self)
.store(in: &cancelables)
}
}
View:
struct TaskListView: View {
#ObservedObject private var taskListViewModel = TaskListViewModel()
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $taskListViewModel.showNotDoneOnly) {
Text("Undone only")
}.padding()
List {
ForEach(taskListViewModel.filterdTasks.indices, id: \.self) { idx in
TaskRow(task: $taskListViewModel.filterdTasks[idx])
}
}
}.navigationBarTitle(Text("Tasks"))
}
}
}
TaskRow:
struct TaskRow: View {
#Binding var task: Task
var body: some View {
HStack {
Text(task.name)
Spacer()
Toggle("", isOn: $task.done )
}
}
}
With this approach the list is filtered when the user enable the filter but when it is disabled the list lose the previously filtered elements. If I change the code to restore the filter elements like this:
$showNotDoneOnly.map { notDoneOnly in
if notDoneOnly {
return self.filterdTasks.filter { task in
!task.done
}
}
return self.allTasks
}.assign(to: \.filterdTasks, on: self)
The list lose the edited elements.
I've also tried making allTask property to a #Published dictionary by without success. Any idea on how to implement this? Is ther any better approach to do this in SwiftUi?
Thanks
SwiftUI architecture is really just state and view. Here, it's the state of the Task that you are most interested in (done/undone). Make the Task an Observable class that publishes it's done/undone state change. Bind the UI toggle switch in TaskRow directly to that done/undone in the Task model (remove the intermediary list of indexes), then you don't need any logic to publish state changes manually.
The second state for the app is filtered/unfiltered for the list. That part it seems you already have down.
This is one possible way to do it.
EDIT: Here's a more full example on how to keep the data state and view separate. The Task model is the central idea here.
#main
struct TaskApp: App {
#StateObject var model = Model()
var body: some Scene {
WindowGroup {
TaskListView()
.environmentObject(model)
}
}
}
class Model: ObservableObject {
#Published var tasks: [Task] = [
Task(name: "Task1", description: "Description"),
Task(name: "Task2", description: "Description")
] // some initial sample data
func updateTasks() {
//
}
}
class Task: ObservableObject, Identifiable, Hashable {
var id: String { name }
let name, description: String
#Published var done: Bool = false
init(name: String, description: String) {
self.name = name
self.description = description
}
static func == (lhs: Task, rhs: Task) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct TaskListView: View {
#EnvironmentObject var model: Model
var filter: ([Task]) -> [Task] = { $0.filter { $0.done } }
#State private var applyFilter = false
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $applyFilter) {
Text("Undone only")
}.padding()
List {
ForEach(
(applyFilter ? filter(model.tasks) : model.tasks), id: \.self) { task in
TaskRow(task: task)
}
}
}.navigationBarTitle(Text("Tasks"))
}
}
}
struct TaskRow: View {
#ObservedObject var task: Task
var body: some View {
HStack {
Text(task.name)
Spacer()
Toggle("", isOn: $task.done).labelsHidden()
}
}
}
Finally I've managed to implement the list functionality whith the conditions previously listed. Based on Cenk Bilgen answer:
ListView:
struct TaskListView: View {
#ObservedObject private var viewModel = TaskListViewModel()
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $viewModel.filterDone) {
Text("Filter done")
}.padding()
List {
ForEach(viewModel.filter(), id: \.self) { task in
TaskRow(task: task)
}
}
}.navigationBarTitle(Text("Tasks"))
}.onAppear {
viewModel.fetchTasks()
}
}
}
TaskRow:
struct TaskRow: View {
#ObservedObject var task: TaskViewModel
var body: some View {
HStack {
Text(task.name)
Spacer()
Toggle("", isOn: $task.done )
}
}
}
TaskListViewModel
class TaskListViewModel : ObservableObject {
private var cancelables = Set<AnyCancellable>()
#Published var filterDone = false
#Published var tasks: [TaskViewModel] = []
func filter() -> [TaskViewModel] {
filterDone ? tasks.filter { !$0.done } : tasks
}
func fetchTasks() {
let id = 0
[
TaskViewModel(name: "Task \(id)", description: "Description"),
TaskViewModel(name: "Task \(id + 1)", description: "Description")
].forEach { add(task: $0) }
}
private func add(task: TaskViewModel) {
tasks.append(task)
task.objectWillChange
.sink { self.objectWillChange.send() }
.store(in: &cancelables)
}
}
Notice here each TaskViewModel will propagate objectWillChange event to TaskListViewModel to update the filter when a task is marked as completed.
TaskViewModel:
class TaskViewModel: ObservableObject, Identifiable, Hashable {
var id: String { name }
let name: String
let description: String
#Published var done: Bool = false
init(name: String, description: String, done: Bool = false) {
self.name = name
self.description = description
self.done = done
}
static func == (lhs: TaskViewModel, rhs: TaskViewModel) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
This is the main difference from the original approach: Changing the row model from a simple struct included as #Binding to an ObservableObject

How to reset child view state variable with SwiftUI?

I'm sure it's something very silly but how should one reset the state value of a child view when another state has changed?
For example, the code below shows 2 folders, which respectively have 2 and 3 items., which can be edited.
If you select the second folder (Work) and its 3rd item (Peter) and then select the first folder (Home), the app crashes since selectedItemIndex is out of bounds.
I tried to "reset" the state value when the view gets initialized but it seems like changing the state like such triggers out a "runtime: SwiftUI: Modifying state during view update, this will cause undefined behavior." warning.
init(items: Binding<[Item]>) {
self._items = items
self._selectedItemIndex = State(wrappedValue: 0)
}
What is the proper way to do this? Thanks!
Here's the code:
AppDelegate.swift
import Cocoa
import SwiftUI
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let store = ItemStore()
let contentView = ContentView(store: store)
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
}
ContentView.swift
import SwiftUI
final class ItemStore: ObservableObject {
#Published var data: [Folder] = [Folder(name: "Home",
items: [Item(name: "Mark"), Item(name: "Vincent")]),
Folder(name: "Work",
items:[Item(name: "Joseph"), Item(name: "Phil"), Item(name: "Peter")])]
}
struct Folder: Identifiable {
var id = UUID()
var name: String
var items: [Item]
}
struct Item: Identifiable {
static func == (lhs: Item, rhs: Item) -> Bool {
return true
}
var id = UUID()
var name: String
var content = Date().description
init(name: String) {
self.name = name
}
}
struct ContentView: View {
#ObservedObject var store: ItemStore
#State var selectedFolderIndex: Int?
var body: some View {
HSplitView {
// FOLDERS
List(selection: $selectedFolderIndex) {
Section(header: Text("Groups")) {
ForEach(store.data.indexed(), id: \.1.id) { index, folder in
Text(folder.name).tag(index)
}
}.collapsible(false)
}
.listStyle(SidebarListStyle())
// ITEMS
if selectedFolderIndex != nil {
ItemsView(items: $store.data[selectedFolderIndex!].items)
}
}
.frame(minWidth: 800, maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ItemsView: View {
#Binding var items: [Item]
#State var selectedItemIndex: Int?
var body: some View {
HSplitView {
List(selection: $selectedItemIndex) {
ForEach(items.indexed(), id: \.1.id) { index, item in
Text(item.name).tag(index)
}
}
.frame(width: 300)
if selectedItemIndex != nil {
DetailView(item: $items[selectedItemIndex!])
.padding()
.frame(minWidth: 200, maxHeight: .infinity)
}
}
}
init(items: Binding<[Item]>) {
self._items = items
self._selectedItemIndex = State(wrappedValue: 0)
}
}
struct DetailView: View {
#Binding var item: Item
var body: some View {
VStack {
TextField("", text: $item.name)
}
}
}
// Credit: https://swiftwithmajid.com/2019/07/03/managing-data-flow-in-swiftui/
struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
typealias Index = Base.Index
typealias Element = (index: Index, element: Base.Element)
let base: Base
var startIndex: Index { base.startIndex }
var endIndex: Index { base.endIndex }
func index(after i: Index) -> Index {
base.index(after: i)
}
func index(before i: Index) -> Index {
base.index(before: i)
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
base.index(i, offsetBy: distance)
}
subscript(position: Index) -> Element {
(index: position, element: base[position])
}
}
extension RandomAccessCollection {
func indexed() -> IndexedCollection<Self> {
IndexedCollection(base: self)
}
}
Thanks to #jordanpittman for suggesting a fix:
ItemsView(items: $store.data[selectedFolderIndex!].items).id(selectedRowIndex)
Source: https://swiftui-lab.com/swiftui-id
Fully playable sample draft for ContentView.swift. Play with it in both edit modes (inactive/active row selection) and adopt to your needs.
import SwiftUI
struct ItemStore {
var data: [Folder] = [Folder(name: "Home", items: [Item(name: "Mark"), Item(name: "Vincent")]),
Folder(name: "Work", items:[Item(name: "Joseph"), Item(name: "Phil"), Item(name: "Peter")])]
}
struct Folder: Identifiable {
var id = UUID()
var name: String
var items: [Item]
}
struct Item: Identifiable {
var id = UUID()
var name: String
var content = Date().description
}
struct ContentView: View {
#State var store: ItemStore
#State var selectedFolderIndex: Int? = 0
#State private var editMode = EditMode.inactive
var body: some View {
NavigationView {
VStack {
// FOLDERS
List(selection: $selectedFolderIndex) {
Section(header: Text("Groups")) {
ForEach(store.data.indexed(), id: \.1.id) { index, folder in
HStack {
Text(folder.name).tag(index)
Spacer()
}
.background(Color.white) //make the whole row tapable, not just the text
.frame(maxWidth: .infinity)
.multilineTextAlignment(.leading)
.onTapGesture {
self.selectedFolderIndex = index
}
}.onDelete(perform: delete)
}
}
.listStyle(GroupedListStyle())
.id(selectedFolderIndex)
// ITEMS
if selectedFolderIndex != nil && (($store.data.wrappedValue.startIndex..<$store.data.wrappedValue.endIndex).contains(selectedFolderIndex!) ){
ItemsView(items: $store.data[selectedFolderIndex!].items)
}
}
.navigationBarTitle("Title")
.navigationBarItems(trailing: EditButton())
.environment(\.editMode, $editMode)
}
}
func delete(at offsets: IndexSet) {
$store.wrappedValue.data.remove(atOffsets: offsets) // Note projected value! `store.data.remove() will not modify SwiftUI on changes and it will crash because of invalid index.
}
}
struct ItemsView: View {
#Binding var items: [Item]
#State var selectedDetailIndex: Int?
var body: some View {
HStack {
List(selection: $selectedDetailIndex) {
ForEach(items.indexed(), id: \.1.id) { index, item in
Text(item.name).tag(index)
.onTapGesture {
self.selectedDetailIndex = index
}
}
}
if selectedDetailIndex != nil && (($items.wrappedValue.startIndex..<$items.wrappedValue.endIndex).contains(selectedDetailIndex!) ) {
DetailView(item: $items[selectedDetailIndex!])
.padding()
}
}
}
}
struct DetailView: View {
#Binding var item: Item
var body: some View {
VStack {
TextField("", text: $item.name)
}
}
}
// Credit: https://swiftwithmajid.com/2019/07/03/managing-data-flow-in-swiftui/
struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
typealias Index = Base.Index
typealias Element = (index: Index, element: Base.Element)
let base: Base
var startIndex: Index { base.startIndex }
var endIndex: Index { base.endIndex }
func index(after i: Index) -> Index {
base.index(after: i)
}
func index(before i: Index) -> Index {
base.index(before: i)
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
base.index(i, offsetBy: distance)
}
subscript(position: Index) -> Element {
(index: position, element: base[position])
}
}
extension RandomAccessCollection {
func indexed() -> IndexedCollection<Self> {
IndexedCollection(base: self)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(store: ItemStore())
}
}

Resources