TextField in a list not working well in SwiftUI - swift

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)
}
}

Related

SwiftUI TabView: how to detect click on a tab?

I would like to run a function each time a tab is tapped.
On the code below (by using onTapGesture) when I tap on a new tab, myFunction is called, but the tabview is not changed.
struct DetailView: View {
var model: MyModel
#State var selectedTab = 1
var body: some View {
TabView(selection: $selectedTab) {
Text("Graphs").tabItem{Text("Graphs")}
.tag(1)
Text("Days").tabItem{Text("Days")}
.tag(2)
Text("Summary").tabItem{Text("Summary")}
.tag(3)
}
.onTapGesture {
model.myFunction(item: selectedTab)
}
}
}
How can I get both things:
the tabview being normally displayed
my function being called
As of iOS 14 you can use onChange to execute code when a state variable changes. You can replace your tap gesture with this:
.onChange(of: selectedTab) { newValue in
model.myFunction(item: newValue)
}
If you don't want to be restricted to iOS 14 you can find additional options here: How can I run an action when a state changes?
The above answers work well except in one condition. If you are present in the same tab .onChange() won't be called. the better way is by creating an extension to binding
extension Binding {
func onUpdate(_ closure: #escaping () -> Void) -> Binding<Value> {
Binding(get: {
wrappedValue
}, set: { newValue in
wrappedValue = newValue
closure()
})
}
}
the usage will be like this
TabView(selection: $selectedTab.onUpdate{ model.myFunction(item: selectedTab) }) {
Text("Graphs").tabItem{Text("Graphs")}
.tag(1)
Text("Days").tabItem{Text("Days")}
.tag(2)
Text("Summary").tabItem{Text("Summary")}
.tag(3)
}
Here is possible approach. For TabView it gives the same behaviour as tapping to the another tab and back, so gives persistent look & feel:
Full module code:
import SwiftUI
struct TestPopToRootInTab: View {
#State private var selection = 0
#State private var resetNavigationID = UUID()
var body: some View {
let selectable = Binding( // << proxy binding to catch tab tap
get: { self.selection },
set: { self.selection = $0
// set new ID to recreate NavigationView, so put it
// in root state, same as is on change tab and back
self.resetNavigationID = UUID()
})
return TabView(selection: selectable) {
self.tab1()
.tabItem {
Image(systemName: "1.circle")
}.tag(0)
self.tab2()
.tabItem {
Image(systemName: "2.circle")
}.tag(1)
}
}
private func tab1() -> some View {
NavigationView {
NavigationLink(destination: TabChildView()) {
Text("Tab1 - Initial")
}
}.id(self.resetNavigationID) // << making id modifiable
}
private func tab2() -> some View {
Text("Tab2")
}
}
struct TabChildView: View {
var number = 1
var body: some View {
NavigationLink("Child \(number)",
destination: TabChildView(number: number + 1))
}
}
struct TestPopToRootInTab_Previews: PreviewProvider {
static var previews: some View {
TestPopToRootInTab()
}
}

Picker Row not getting deselected in Form (SwiftUI, Xcode 12 beta 4)

I am creating a picker in a form in SwiftUI. On selection of a new element in picker, when the view pulls back to the form, the picker row isn't getting deselected. Here is a screenshot of what I mean, the picker row remains grayed out like this.
I read some previous answers which say that this was a bug in Xcode 11.3, however, I'm running Xcode 12 beta 4 and am not sure if this is still a bug.
This is how I'm creating the picker:
struct SettingsView: View {
#State private var currentSelection = 1
var body: some View {
NavigationView {
Form {
Section {
Picker("Test 2", selection: $currentSelection) {
ForEach(1 ...< 100) { i in
Text(String(i)).tag(i)
}
}
}
}
}
}
}
Here is my ContentView, from which I am presenting SettingsView:
enum ActiveSheet: Identifiable {
case photoPicker, settings
var id: Int {
self.hashValue
}
}
struct ContentView: View {
#State var activeSheet: ActiveSheet?
var body: some View {
NavigationView {
VStack {
Text("Hello world")
Button("Select Photo") {
self.activeSheet = .photoPicker
}
}
.navigationBarTitle(Text("Title"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
self.activeSheet = .settings
}, label: {
Image(systemName: "gear")
.imageScale(.large)
}))
}
.sheet(item: $activeSheet) { item in
if item == .photoPicker {
ImagePicker(selectedImage: $image, sourceType: .photoLibrary)
} else {
SettingsView()
}
}
}
}
Edit: I created a brand new project, this is the only code:
import SwiftUI
struct ContentView: View {
#State private var currentSelection = 0
var body: some View {
NavigationView {
Form {
Section {
Picker("Test Picker", selection: $currentSelection) {
ForEach(0 ..< 100) { i in
Text(String(i)).tag(i)
}
}
}
}.navigationBarTitle(Text("Test"))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The issue still persists.
(For the record I am on Big Sur, Xcode 12.2 and this issue is still there.)
After few tests it appears that this behaviour is due to multiple lists inside the same View. You can see that the first "List" (i.e. UITableView) gets its selected row deselected when the navigation stack is popped back to the view, but this doesn't work for the lists after the first one. So clearly this is a bug (in SwiftUI?!).
The only way (and somehow a crappy way) I have found to circumvent this bug is to have the Introspect Library get the tableView of interest (in this case the one displaying the Form) and do some housekeeping like this:
import Introspect
...
public class Weak<T: AnyObject> {
public weak var value: T?
public init(_ value: T? = nil) {
self.value = value
}
}
private var listTableView = Weak<UITableView>()
var body: Some View {
NavigationView {
VStack {
List {
...
}
Form {
Picker(...) {
...
}
}
.introspectTableView { listTableView.value = $0 }
.onAppear() {
if let tableView = listTableView.value, let indexPath = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: indexPath, animated: true)
}
}
}
It works... but with no animation though (anyone?).

Sheet inside ForEach doesn't loop over items SwiftUI

I have an issue using a sheet inside a ForEach. Basically I have a List that shows many items in my array and an image that trigger the sheet. The problem is that when my sheet is presented it only shows the first item of my array which is "Harry Potter" in this case.
Here's the code
struct ContentView: View {
#State private var showingSheet = false
var movies = ["Harry potter", "Mad Max", "Oblivion", "Memento"]
var body: some View {
NavigationView {
List {
ForEach(0 ..< movies.count) { movie in
HStack {
Text(self.movies[movie])
Image(systemName: "heart")
}
.onTapGesture {
self.showingSheet = true
}
.sheet(isPresented: self.$showingSheet) {
Text(self.movies[movie])
}
}
}
}
}
}
There should be only one sheet, so here is possible approach - use another sheet modifier and activate it by selection
Tested with Xcode 12 / iOS 14 (iOS 13 compatible)
extension Int: Identifiable {
public var id: Int { self }
}
struct ContentView: View {
#State private var selectedMovie: Int? = nil
var movies = ["Harry potter", "Mad Max", "Oblivion", "Memento"]
var body: some View {
NavigationView {
List {
ForEach(0 ..< movies.count) { movie in
HStack {
Text(self.movies[movie])
Image(systemName: "heart")
}
.onTapGesture {
self.selectedMovie = movie
}
}
}
.sheet(item: self.$selectedMovie) {
Text(self.movies[$0])
}
}
}
}
I changed your code to have only one sheet and have the selected movie in one variable.
extension String: Identifiable {
public var id: String { self }
}
struct ContentView: View {
#State private var selectedMovie: String? = nil
var movies = ["Harry potter", "Mad Max", "Oblivion", "Memento"]
var body: some View {
NavigationView {
List {
ForEach(movies) { movie in
HStack {
Text(movie)
Image(systemName: "heart")
}
.onTapGesture {
self.selectedMovie = movie
}
}
}
.sheet(item: self.$selectedMovie, content: { selectedMovie in
Text(selectedMovie)
})
}
}
}
Wanted to give my 2 cents on the matter.
I was encountering the same problem and Asperi's solution worked for me.
BUT - I also wanted to have a button on the sheet that dismisses the modal.
When you call a sheet with isPresented you pass a binding Bool and so you change it to false in order to dismiss.
What I did in the item case is I passed the item as a Binding. And in the sheet, I change that binding item to nil and that dismissed the sheet.
So for example in this case the code would be:
var movies = ["Harry potter", "Mad Max", "Oblivion", "Memento"]
var body: some View {
NavigationView {
List {
ForEach(0 ..< movies.count) { movie in
HStack {
Text(self.movies[movie])
Image(systemName: "heart")
}
.onTapGesture {
self.selectedMovie = movie
}
}
}
.sheet(item: self.$selectedMovie) {
Text(self.movies[$0])
// My addition here: a "Done" button that dismisses the sheet
Button {
selectedMovie = nil
} label: {
Text("Done")
}
}
}
}

Insert, update and delete animations with ForEach in SwiftUI

I managed to have a nice insert and delete animation for items displayed in a ForEach (done via .transition(...) on Row). But sadly this animation is also triggered when I just update the name of Item in the observed array. Of course this is because it actually is a new view (you can see that, since onAppear() of Row is called).
As we all know the recommended way of managing lists with cool animations would be List but I think that many people would like to avoid the standard UI or the limitations that come along with this element.
A working SwiftUI example snippet is attached (Build with Xcode 11.4)
So, the question:
Is there a smart way to suppress the animation (or have another one) for just updated items that would keep the same position? Is there a cool possibility to "reuse" the row and just update it?
Or is the answer "Let's wait for the next WWDC and let's see if Apple will fix it..."? ;-)
Cheers,
Orlando 🍻
Edit
bonky fronks answer is actually a good approach when you can distinguish between edit/add/delete (e.g. by manual user actions). As soon as the items array gets updated in background (for example by synced updates coming from Core Data in your view model) you don't know if this is an update or not. But maybe in this case the answer would be to manually implement the insert/update/delete cases in the view model.
struct ContentView: View {
#State var items: [Item] = [
Item(name: "Tim"),
Item(name: "Steve"),
Item(name: "Bill")
]
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}
private var AddButton: some View {
Button(action: {
self.items.insert(Item(name: "Jeff"), at: 0)
}) {
Text("Add")
}
}
private var RenameButton: some View {
Button(action: {
self.items[0].name = "Craigh"
}) {
Text("Rename first")
}
}
}
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.animation(.spring())
.transition(.move(edge: .leading))
}
}
struct Item: Identifiable, Hashable {
let id: UUID
var name: String
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Luckily this is actually really easy to do. Simply remove .animation(.spring()) on your Row, and wrap any changes in withAnimation(.spring()) { ... }.
So the add button will look like this:
private var AddButton: some View {
Button(action: {
withAnimation(.spring()) {
self.items.insert(Item(name: "Jeff"), at: 0)
}
}) {
Text("Add")
}
}
and your Row will look like this:
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.transition(.move(edge: .leading))
}
}
The animation must be added on the VStack with the modifier animation(.spring, value: items) where items is the value with respect to which you want to animate the view. items must be an Equatable value.
This way, you can also animate values that you receive from your view model.
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
.animation(.spring(), value: items) // <<< here
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}

SwiftUI: Fire an event when a toggle is switched

I need to post notification when the state of a toggle changes. I couldn't find a way to specify an action for a toggle. Any idea how I can do that?
var body: some View {
List {
ForEach(items.indices) { index in
Section(header: Text(self.items[index].label)) {
Toggle(isOn: self.$items[index].isOn) {
Text("Enabled")
}
}
}
}
.listStyle(GroupedListStyle())
}
But then what??
In general, you don't want your view to be in charge of executing code when it changes, because your view is not the source of truth - it merely responds to changes in your source of truth.
In this case, what you want is a view model that is in charge of keeping your view's state. When it changes, your view reacts. Then you can have that view model execute code when one of its properties changes (using didSet(), for example).
struct ContentView: View {
#ObservedObject var model = ListModel()
var body: some View {
List {
ForEach(0..<model.sections.count, id: \.self) { index in
Section(header: Text(self.model.sections[index].label as String)) {
Toggle(isOn: self.$model.sections[index].enabled) {
Text("Enabled")
}
}
}
}
.listStyle(GroupedListStyle())
}
}
class ListModel: ObservableObject {
#Published var sections: [ListSection] = [
ListSection(label: "Section One"),
ListSection(label: "Section Two"),
ListSection(label: "Section Three")
]
}
struct ListSection {
var label: String
var enabled: Bool = false {
didSet {
// Here's where any code goes that needs to run when a switch is toggled
print("\(label) is \(enabled ? "enabled" : "disabled")")
}
}
}