SwiftUI callback when (de)selecting an item - swift

I have the following List:
List(selection: self.$selectionKeeper) {
ForEach(self.items, id: \.self) { name in
Text(name)
}
}
.environment(\.editMode, .constant(.active))
How can I get a callback each time an item is (de)selected? I know I can listen for changes in selectionKeeper, but I thought there might be a better way

Yes, the easiest way is to use onChange (or onReceive in iOS 13) to monitor changes to selectionKeeper:
List(selection: self.$selectionKeeper) {
ForEach(0..<5, id: \.self) { item in
Text("\(item)")
}
}
.environment(\.editMode, .constant(.active))
.onChange(of: selectionKeeper) { selectedItems in
print(selectedItems)
}
If you want to do it in another way, you can create a custom binding:
struct ContentView: View {
#State private var selectionKeeper = Set<Int>()
var body: some View {
List(selection: .init(
get: {
selectionKeeper
},
set: {
selectionKeeper = $0
selectionCallback($0)
}
)) {
ForEach(0..<5, id: \.self) { item in
Text("\(item)")
}
}
.environment(\.editMode, .constant(.active))
}
func selectionCallback(_ selectedItems: Set<Int>) {
print(selectedItems)
}
}
You can also extract the binding as a computed property:
var listBinding: Binding<Set<Int>> {
.init(
get: {
selectionKeeper
},
set: {
selectionKeeper = $0
selectionCallback($0)
}
)
}
List(selection: listBinding) { ... }
The onChange solution is simpler and more useful when you only need to react to changes to a specific variable. However, with a custom binding you get much more flexibility (you can change both get and set).

Related

SwiftUI: NavigationView detail view pops backstack when previous's view List changes

I have a List of ids and scores in my first screen.
In the detail screen I click and call a callback that adds to the score and resorts the List by the score.
When I do this with an item at the top of the list, nothing happens. (Good)
When I do this with an item at the bottom of the list, the navigation view pops the backstack and lands me back on the first page. (Bad)
import SwiftUI
class IdAndScoreItem {
var id: Int
var score: Int
init(id: Int, score: Int) {
self.id = id
self.score = score
}
}
#main
struct CrazyBackStackProblemApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ListView()
}
.navigationViewStyle(.stack)
}
}
}
struct ListView: View {
#State var items = (1...50).map { IdAndScoreItem(id: $0, score: 0) }
func addScoreAndSort(item: IdAndScoreItem) {
items = items
.map {
if($0.id == item.id) { $0.score += 1 }
return $0
}
.sorted {
$0.score > $1.score
}
}
var body: some View {
List(items, id: \.id) { item in
NavigationLink {
ScoreClickerView(
onClick: { addScoreAndSort(item: item) }
)
} label: {
Text("id: \(item.id) score:\(item.score)")
}
}
}
}
struct ScoreClickerView: View {
var onClick: () -> Void
var body: some View {
Text("tap me to increase the score")
.onTapGesture {
onClick()
}
}
}
How can I make it so I reorder the list on the detail page, and that's reflected on the list page, but the navigation stack isn't popped (when I'm doing it on a list item at the bottom of the list). I tried added navigationStyle(.stack) to no avail.
Thanks for any and all help!
Resort changes order of IDs making list recreate content that leads to current NavigationLinks destroying, so navigating back.
A possible solution is to separate link from content - it can be done with introducing something like selection (tapped row) and one navigation link activated with that selection.
Tested with Xcode 14 / iOS 16
#State private var selectedItem: IdAndScoreItem? // selection !!
var isNavigate: Binding<Bool> { // link activator !!
Binding(get: { selectedItem != nil}, set: { _ in selectedItem = nil })
}
var body: some View {
List(items, id: \.id) { item in
Text("id: \(item.id) score:\(item.score)") // tappable row
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
selectedItem = item
}
}
.background(
NavigationLink(isActive: isNavigate) { // one link !!
ScoreClickerView {
if let item = selectedItem {
addScoreAndSort(item: item)
}
}
} label: {
EmptyView()
}
)
}
Do your sorting on onAppear. No need to sort on each click.
struct ListView: View {
#State var items = (1...50).map { IdAndScoreItem(id: $0, score: 0) }
func addScoreAndSort(item: IdAndScoreItem) {
item.score += 1
}
var body: some View {
List(items, id: \.id) { item in
NavigationLink {
ScoreClickerView(
onClick: { addScoreAndSort(item: item) }
)
} label: {
Text("id: \(item.id) score:\(item.score)")
}
}.onAppear { // <==== Here
items = items
.sorted {
$0.score > $1.score
}
}
}
}
Note : No need to use map here. since you are using class so it will update with reference.

TextField in a list not working well in SwiftUI

This problem is with SwiftUI for a iPhone 12 app, Using xcode 13.1.
I build a List with TextField in each row, but every time i try to edit the contents, it is only allow me tap one time and enter only one character then can not keep enter characters anymore, unless i tap again then enter another one character.Did i write something code wrong with it?
class PieChartViewModel: ObservableObject, Identifiable {
#Published var options = ["How are you", "你好", "Let's go to zoo", "OKKKKK", "什麼情況??", "yesssss", "二百五", "明天見"]
}
struct OptionsView: View {
#ObservedObject var viewModel: PieChartViewModel
var body: some View {
NavigationView {
List {
ForEach($viewModel.options, id: \.self) { $option in
TextField(option, text: $option)
}
}
.navigationTitle("Options")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button {
addNewOption()
} label: {
HStack {
Image(systemName: "plus")
Text("Create a new option")
}
}
}
}
}
}
func addNewOption() {
viewModel.options.insert("", at: viewModel.options.count)
}
}
struct OptionsView_Previews: PreviewProvider {
static var previews: some View {
let pieChart = PieChartViewModel()
OptionsView(viewModel: pieChart)
}
}
Welcome to StackOverflow! Your issue is that you are directly updating an ObservableObject in the TextField. Every change you make to the model, causes a redraw of your view, which, of course, kicks your focus from the TextField. The easiest answer is to implement your own Binding on the TextField. That will cause the model to update, without constantly redrawing your view:
struct OptionsView: View {
// You should be using #StateObject instead of #ObservedObject, but either should work.
#StateObject var model = PieChartViewModel()
#State var newText = ""
var body: some View {
NavigationView {
VStack {
List {
ForEach(model.options, id: \.self) { option in
Text(option)
}
}
List {
//Using Array(zip()) allows you to sort by the element, but use the index.
//This matters if you are rearranging or deleting the elements in a list.
ForEach(Array(zip(model.options, model.options.indices)), id: \.0) { option, index in
// Binding implemented here.
TextField(option, text: Binding<String>(
get: {
model.options[index]
},
set: { newValue in
//You can't update the model here because you will get the same behavior
//that you were getting in the first place.
newText = newValue
}))
.onSubmit {
//The model is updated here.
model.options[index] = newText
newText = ""
}
}
}
.navigationTitle("Options")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button {
addNewOption()
} label: {
HStack {
Image(systemName: "plus")
Text("Create a new option")
}
}
}
}
}
}
}
func addNewOption() {
model.options.insert("", at: model.options.count)
}
}

Deselecting item from a picker SwiftUI

I use a form with a picker, and everything works fine (I am able to select an element from the picker), but I cannot deselect it. Does there exist a way to deselect an item from the picker?
Thank you!
Picker(selection: $model.countries, label: Text("country")) {
ForEach(model.countries, id: \.self) { country in
Text(country!.name)
.tag(country)
}
}
To deselect we need optional storage for picker value, so here is a demo of possible approach.
Tested with Xcode 12.1 / iOS 14.1
struct ContentView: View {
#State private var value: Int?
var body: some View {
NavigationView {
Form {
let selected = Binding(
get: { self.value },
set: { self.value = $0 == self.value ? nil : $0 }
)
Picker("Select", selection: selected) {
ForEach(0...9, id: \.self) {
Text("\($0)").tag(Optional($0))
}
}
}
}
}
}
I learned almost all I know about SwiftUI Bindings (with Core Data) by reading this blog by Jim Dovey. The remainder is a combination of some research and many hours of making mistakes.
So when I combine Jim's technique to create Extensions on SwiftUI Binding with Asperi's answer, then we end up with something like this...
public extension Binding where Value: Equatable {
init(_ source: Binding<Value>, deselectTo value: Value) {
self.init(get: { source.wrappedValue },
set: { source.wrappedValue = $0 == source.wrappedValue ? value : $0 }
)
}
}
Which can then be used throughout your code like this...
Picker("country", selection: Binding($selection, deselectTo: nil)) { ... }
OR
Picker("country", selection: Binding($selection, deselectTo: someOtherValue)) { ... }
First of, we can fix the selection. It should match the type of the tag. The tag is given Country, so to have a selection where nothing might be selected, we should use Country? as the selection type.
It should looks like this:
struct ContentView: View {
#ObservedObject private var model = Model()
#State private var selection: Country?
var body: some View {
NavigationView {
Form {
Picker(selection: $selection, label: Text("country")) {
ForEach(model.countries, id: \.self) { country in
Text(country!.name)
.tag(country)
}
}
Button("Clear") {
selection = nil
}
}
}
}
}
You then just need to set the selection to nil, which is done in the button. You could set selection to nil by any action you want.
If your deployment target is set to iOS 14 or higher -- Apple has provided a built-in onChange extension to View where you can deselect your row using tag, which can be used like this instead (Thanks)
Picker(selection: $favoriteColor, label: Text("Color")) {
// ..
}
.onChange(of: favoriteColor) { print("Color tag: \($0)") }

SwiftUI Reorder list dynamic sections from another view

I have a simple List with sections that are stored inside an ObservableObject. I'd like to reorder them from another view.
This is my code:
class ViewModel: ObservableObject {
#Published var sections = ["S1", "S2", "S3", "S4"]
func move(from source: IndexSet, to destination: Int) {
sections.move(fromOffsets: source, toOffset: destination)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var showOrderingView = false
var body: some View {
VStack {
Button("Reorder sections") {
self.showOrderingView = true
}
list
}
.sheet(isPresented: $showOrderingView) {
OrderingView(viewModel: self.viewModel)
}
}
var list: some View {
List {
ForEach(viewModel.sections, id: \.self) { section in
Section(header: Text(section)) {
ForEach(0 ..< 3, id: \.self) { _ in
Text("Item")
}
}
}
}
}
}
struct OrderingView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.sections, id: \.self) { section in
Text(section)
}
.onMove(perform: viewModel.move)
}
.navigationBarItems(trailing: EditButton())
}
}
}
But in the OrderingView when trying to move sections I'm getting this error: "Attempt to create two animations for cell". Likely it's because the order of the sections has changed.
How can I change the order of the sections?
The problem of this scenario is recreated many times ViewModel, so modifications made in sheet just lost. (The strange thing is that in SwiftUI 2.0 with StateObject these changes also lost and EditButton does not work at all.)
Anyway. It looks like here is a found workaround. The idea is to break interview dependency (binding) and work with pure data passing them explicitly into sheet and return them back explicitly from it.
Tested & worked with Xcode 12 / iOS 14, but I tried to avoid using SwiftUI 2.0 features.
class ViewModel: ObservableObject {
#Published var sections = ["S1", "S2", "S3", "S4"]
func move(from source: IndexSet, to destination: Int) {
sections.move(fromOffsets: source, toOffset: destination)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var showOrderingView = false
var body: some View {
VStack {
Button("Reorder sections") {
self.showOrderingView = true
}
list
}
.sheet(isPresented: $showOrderingView) {
OrderingView(sections: viewModel.sections) {
self.viewModel.sections = $0
}
}
}
var list: some View {
List {
ForEach(viewModel.sections, id: \.self) { section in
Section(header: Text(section)) {
ForEach(0 ..< 3, id: \.self) { _ in
Text("Item")
}
}
}
}
}
}
struct OrderingView: View {
#State private var sections: [String]
let callback: ([String]) -> ()
init(sections: [String], callback: #escaping ([String]) -> ())
{
self._sections = State(initialValue: sections)
self.callback = callback
}
var body: some View {
NavigationView {
List {
ForEach(sections, id: \.self) { section in
Text(section)
}
.onMove {
self.sections.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
.onDisappear {
self.callback(self.sections)
}
}
}
A possible workaround solution for SwiftUI 1.0
I found a workaround to disable animations for the List by adding .id(UUID()):
var list: some View {
List {
...
}
.id(UUID())
}
This, however, messes the transition animations for NavigationLinks created with NavigationLink(destination:tag:selection:): Transition animation gone when presenting a NavigationLink in SwiftUI.
And all other animations (like onDelete) are missing as well.
The even more hacky solution is to disable list animations conditionally:
class ViewModel: ObservableObject {
...
#Published var isReorderingSections = false
...
}
struct OrderingView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
...
}
.onAppear {
self.viewModel.isReorderingSections = true
}
.onDisappear {
self.viewModel.isReorderingSections = false
}
}
}
struct ContentView: View {
...
var list: some View {
List {
...
}
.id(viewModel.isReorderingSections ? UUID().hashValue : 1)
}
}

SwiftUI: Update NavigationView after deletion (iPad)

I want to show the empty view (here: Text("Please select a person.")) after the deletion of a row has happend on an iPad.
Currently: The detail view on an iPad will not get updated after the deletion of an item.
Expected: Show the empty view after the selected item gets deleted.
struct DetailView: View {
var name: String
var body: some View {
Text("Detail of \(name)")
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user)) {
Text(user)
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
}
}
NavigationView example from Hacking With Swift.
In the example below, the detail view is shown correctly after the first launch: here
But after the deletion of a row, the previously selected detail view (here: Paul) is still shown: here
As of iOS 14, deleting an element from a list in a navigation view does not seem to be supported.
The NavigationLink type takes an isActive binding, but that does not work in the case of deletion. When you receive the .onDelete callback it is too late. That NavigationLink is not in the list anymore and any change to the binding you passed to it is not going to have any effect.
The workaround is to pass a binding to the DetailView with all the elements, so that it can verify if an element is present and display some content accordingly.
struct DetailView: View {
var name: String
#Binding var users: [String]
var body: some View {
if users.contains(name) {
Text("Detail of \(name)")
} else {
EmptyView()
}
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user, users: $users)) {
Text(user)
}
}
.onDelete(perform: delete)
}
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
}
}
You can use a binding from parent view to manually trigger re-rendering (otherwise the child view won't get notified):
import SwiftUI
struct DetailView: View {
var name: String
#Binding var notifier: Int
#State var deleted: Bool = false
var body: some View {
Group {
if !deleted {
Text("Detail of \(name)")
.onChange(of: notifier, perform: { _ in deleted = true })
} else {
Text("Deleted")
}
}
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
#State private var deleteNotifier: Int = 0
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user,
notifier: $deleteNotifier)) {
Text(user)
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
deleteNotifier += 1
}
}
You can use the second choice for the list updating: index. That way it will reinforce the refreshing:
var body: some View {
NavigationView {
List {
ForEach(users.indices, id: \.self) { index in
NavigationLink(destination: DetailView(name: self.users[index])) {
Text(self.users[index])
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}