Unable to repeat Picker Selection - swiftui

Scenario:
I have a simple picker within a form.
I select a picker item (with chevron) from the form row.
I choose an item (row) from a list of items in the result panel.
The result panel slides away to reveal the original panel.
I am NOT able to repeat this procedure.
Here's my code:
class ChosenView: ObservableObject {
static let choices = ["Modal", "PopOver", "Circle", "CircleImage", "Scroll", "Segment", "Tab", "Multi-Line"]
#Published
var type = 0
}
struct ContentView: View {
#ObservedObject var chosenView = ChosenView()
#State private var isPresented = false
var body: some View {
VStack {
NavigationView {
Form {
Picker(selection: $chosenView.type, label: Text("The Panels")) {
ForEach(0..<ChosenView.choices.count) {
Text(ChosenView.choices[$0]).tag($0)
}
}
}.navigationBarTitle(Text("Available Views"))
.actionSheet(isPresented: $isPresented, content: {
ActionSheet(title: Text("Hello"))
})
}
Section {
Button(action: launchView) {
Text("Select: \(ChosenView.choices[chosenView.type])")
}
}
Spacer()
}
}
private func launchView() {
isPresented = true
}
}
What am I missing?
Why can't I repeat picker selection rather than having to reboot?

Related

In SwiftUI List View refresh triggered whenever underlying datasource of list is updated from a view far away in hierarchy

I am trying to write a "Single View App" in SwiftUI. The main design is very simple. I have a list of items (say Expense) which I am displaying in main view in NavigationView -> List.
List View Source Code
import SwiftUI
struct AmountBasedModifier : ViewModifier{
var amount: Int
func body(content: Content) -> some View {
if amount <= 10{
return content.foregroundColor(Color.green)
}
else if amount <= 100{
return content.foregroundColor(Color.blue)
}
else {
return content.foregroundColor(Color.red)
}
}
}
extension View {
func amountBasedStyle(amount: Int) -> some View {
self.modifier(AmountBasedModifier(amount: amount))
}
}
struct ExpenseItem: Identifiable, Codable {
var id = UUID()
var name: String
var type: String
var amount: Int
static var Empty: ExpenseItem{
return ExpenseItem(name: "", type: "", amount: 0)
}
}
class Expenses: ObservableObject {
#Published var items = [ExpenseItem](){
didSet{
let encoder = JSONEncoder()
if let data = try? encoder.encode(items){
UserDefaults.standard.set(data, forKey: "items")
}
}
}
init() {
let decoder = JSONDecoder()
if let data = UserDefaults.standard.data(forKey: "items"){
if let items = try? decoder.decode([ExpenseItem].self, from: data){
self.items = items
return
}
}
items = []
}
}
struct ContentView: View {
#ObservedObject var expenses = Expenses()
#State private var isShowingAddNewItemView = false
var body: some View {
NavigationView{
List{
ForEach(self.expenses.items) { item in
NavigationLink(destination: ExpenseItemHost(item: item, expenses: self.expenses)){
HStack{
VStack(alignment: .leading){
Text(item.name)
.font(.headline)
Text(item.type)
.font(.subheadline)
}
Spacer()
Text("$\(item.amount)")
.amountBasedStyle(amount: item.amount)
}
}
}.onDelete(perform: removeItems)
}
.navigationBarTitle("iExpense")
.navigationBarItems(leading: EditButton(), trailing: Button(action:
{
self.isShowingAddNewItemView.toggle()
}, label: {
Image(systemName: "plus")
}))
.sheet(isPresented: $isShowingAddNewItemView) {
AddNewExpense(expenses: self.expenses)
}
}
}
func removeItems(at offsets: IndexSet){
self.expenses.items.remove(atOffsets: offsets)
}
}
Each row item is NavigationLink that opens the Expense in readonly mode showing all the attributes of Expense Item.
There is an Add button at the top right to let user add a new expense item in list. The AddNewExpenseView (shown as sheet) has access to the list data source. So whenever user adds an new expense then data source of list is updated (by appending new item) and the sheet is dismissed.
Add View Source Code
struct AddNewExpense: View {
#ObservedObject var expenses: Expenses
#Environment(\.presentationMode) var presentationMode
#State private var name = ""
#State private var type = "Personal"
#State private var amount = ""
#State private var isShowingAlert = false
static private let expenseTypes = ["Personal", "Business"]
var body: some View {
NavigationView{
Form{
TextField("Name", text: $name)
Picker("Expense Type", selection: $type) {
ForEach(Self.expenseTypes, id: \.self) {
Text($0)
}
}
TextField("Amount", text: $amount)
}.navigationBarTitle("Add New Expense", displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
if let amount = Int(self.amount){
let expenseItem = ExpenseItem(name: self.name, type: self.type, amount: amount)
self.expenses.items.append(expenseItem)
self.presentationMode.wrappedValue.dismiss()
}else{
self.isShowingAlert.toggle()
}
}, label: {
Text("Save")
}))
.alert(isPresented: $isShowingAlert) {
Alert.init(title: Text("Invalid Amount"), message: Text("The amount should only be numbers and without decimals"), dismissButton: .default(Text("OK")))
}
}
}
}
Expense Detail (Read Only) View Source Code
struct ExpenseItemView: View {
var item: ExpenseItem
var body: some View {
List{
Section{
Text("Name")
.font(.headline)
Text(item.name)
}
Section{
Text("Expense Type")
.font(.headline)
Text(item.type)
}
Section{
Text("Amount")
.font(.headline)
Text("$\(item.amount)")
}
}.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Expense Details"), displayMode: .inline)
}
}
So far everything good. I then thought of adding an Edit button on the ExpenseItem View screen so that user can edit the Expense. I created an edit View which is launched as a sheet from ReadOnly View when Edit button is clicked.
Edit View Code
struct ExpenseItemHost: View {
#State var isShowingEditSheet = false
#State var item: ExpenseItem
#State var itemUnderEdit = ExpenseItem.Empty
var expenses: Expenses
var body: some View {
VStack{
ExpenseItemView(item: self.item)
}
.navigationBarItems(trailing: Button("Edit")
{
self.isShowingEditSheet.toggle()
})
.sheet(isPresented: $isShowingEditSheet) {
EditExpenseItemView(item: self.$itemUnderEdit)
.onAppear(){
self.itemUnderEdit = self.item
}
.onDisappear(){
//TO DO: Handle the logic where save is done when user has explicitly pressed "Done" button. `//Presently it is saving even if Cancel button is clicked`
if let indexAt = self.expenses.items.firstIndex( where: { listItem in
return self.item.id == listItem.id
}){
self.expenses.items.remove(at: indexAt)
}
self.item = self.itemUnderEdit
self.expenses.items.append(self.item)
}
}
}
}
struct EditExpenseItemView: View {
#Environment(\.presentationMode) var presentationMode
#Binding var item: ExpenseItem
static private let expenseTypes = ["Personal", "Business"]
var body: some View {
NavigationView{
Form{
TextField("Name", text: self.$item.name)
Picker("Expense Type", selection: self.$item.type) {
ForEach(Self.expenseTypes, id: \.self) {
Text($0)
}
}
TextField("Amount", value: self.$item.amount, formatter: NumberFormatter())
}
.navigationBarTitle(Text(""), displayMode: .inline)
.navigationBarItems(leading: Button("Cancel"){
self.presentationMode.wrappedValue.dismiss()
}, trailing: Button("Done"){
self.presentationMode.wrappedValue.dismiss()
})
}
}
}
Screenshots
Problem
I expect that when user is done with editing by pressing Done button the Sheet should come back to ReadOnly screen as this is where user clicked Edit button. But since I am modifying the data source of ListView when Done button is clicked so the ListView is getting recreated/refreshed. So instead of EditView sheet returning to ReadOnly view, the ListView is getting displayed when Done button is clicked.
Since my code is changing the data source of a view which is right now not accessible to user so below exception is also getting generated
2020-08-02 19:30:11.561793+0530 iExpense[91373:6737004] [TableView] Warning once only: UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window). This may cause bugs by forcing views inside the table view to load and perform layout without accurate information (e.g. table view bounds, trait collection, layout margins, safe area insets, etc), and will also cause unnecessary performance overhead due to extra layout passes. Make a symbolic breakpoint at UITableViewAlertForLayoutOutsideViewHierarchy to catch this in the debugger and see what caused this to occur, so you can avoid this action altogether if possible, or defer it until the table view has been added to a window. Table view: <_TtC7SwiftUIP33_BFB370BA5F1BADDC9D83021565761A4925UpdateCoalescingTableView: 0x7f9a8b021800; baseClass = UITableView; frame = (0 0; 414 896); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x6000010a1110>; layer = <CALayer: 0x600001e8c0e0>; contentOffset: {0, -140}; contentSize: {414, 220}; adjustedContentInset: {140, 0, 34, 0}; dataSource: <_TtGC7SwiftUIP13$7fff2c9a5ad419ListCoreCoordinatorGVS_20SystemListDataSourceOs5Never_GOS_19SelectionManagerBoxS2___: 0x7f9a8a5073f0>>
I can understand why ListView refresh is getting triggered but what I could not figure out is the correct pattern to edit the model as well as not cause the ListView refresh to trigger when we have intermediate screen in between i.e. List View -> ReadOnly -> Edit View.
What is the suggestion to handle this case?

SwiftUI selection in lists not working on reused cells

Consider the following project with two views. The first view presents the second one:
import SwiftUI
struct ContentView: View {
private let data = 0...1000
#State private var selection: Set<Int> = []
#State private var shouldShowSheet = false
var body: some View {
self.showSheet()
//self.showPush()
}
private func showSheet() -> some View {
Button(action: {
self.shouldShowSheet = true
}, label: {
Text("Selected: \(selection.count) items")
}).sheet(isPresented: self.$shouldShowSheet) {
EditFormView(selection: self.$selection)
}
}
private func showPush() -> some View {
NavigationView {
Button(action: {
self.shouldShowSheet = true
}, label: {
NavigationLink(destination: EditFormView(selection: self.$selection),
isActive: self.$shouldShowSheet,
label: {
Text("Selected: \(selection.count) items")
})
})
}
}
}
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
#State private var editMode: EditMode = .active
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
Text("\(value)")
}
}.environment(\.editMode, self.$editMode)
}
}
Steps to reproduce:
Create an app with the above two views
Run the app and present the sheet with the editable list
Select some items at random indexes, for example a handful at index 0-10 and another handful at index 90-100
Close the sheet by swiping down/tapping back button
Open the sheet again
Scroll to indexes 90-100 to view the selection in the reused cells
Expected:
The selected indexes as you had will be in “selected state”
Actual:
The selection you had before is not marked as selected in the UI, even though the binding passed to List contains those indexes.
This occurs both on the “sheet” presentation and the “navigation link” presentation.
If you select an item in the list, the “redraw” causes the original items that were originally not shown as selected to now be shown as selected.
Is there a way around this?
It looks like EditMode bug, worth submitting feedback to Apple. The possible solution is to use custom selection feature.
Here is a demo of approach (modified only part). Tested & worked with Xcode 11.4 / iOS 13.4
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
self.cell(for: value)
}
}
}
// also below can be separated into standalone view
private func cell(for value: Int) -> some View {
let selected = self.selection.contains(value)
return HStack {
Image(systemName: selected ? "checkmark.circle" : "circle")
.foregroundColor(selected ? Color.blue : nil)
.font(.system(size: 24))
.onTapGesture {
if selected {
self.selection.remove(value)
} else {
self.selection.insert(value)
}
}.padding(.trailing, 8)
Text("\(value)")
}
}
}

SwiftUI: Picker content not refreshed when adding Element

I have a Picker Element in a VStack, but when its content changes by adding a new Element, the Picker does not refresh.
After hiding and showing the Picker, the new Element is visible.
Does anybody have any idea how to refresh the content of a Picker, without needing to hide / show it?
You can reproduce this by creating a new SwiftUI project and copying the following code instead of the "ContentView" struct.
class ContentModel {
#Published var pickerData: [String] = ["1"]
func addPickerData() {
pickerData.append("\(pickerData.count + 1)")
}
}
struct ContentView: View {
let contentModel = ContentModel()
#State private var showPicker = false
#State private var selectedPickerValue = ""
var body: some View {
VStack(spacing: 8) {
Text("Adding a new Element to the Picker does not refresh its content :-(")
Button(action: {
self.contentModel.addPickerData()
}) {
Text("Add Picker Data")
}
Button(action: {
self.showPicker.toggle()
}) {
Text("Show / Hide 2nd Picker")
}
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
if (showPicker) {
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}
Text("Selected Value: \(selectedPickerValue)")
}
}
}
Thanks in advance for any help!
Here is the trick of reactive and always use two copies of same thing when you need to refresh something.
class ContentModel{
#Published var pickerData: [String] = ["1"]
func addPickerData() {
pickerData.append("\(pickerData.count + 1)")
}
}
struct ContentSSView: View {
let contentModel = ContentModel()
#State private var showPicker = false
#State private var selectedPickerValue = ""
var body: some View {
VStack(spacing: 8) {
Text("Adding a new Element to the Picker does not refresh its content :-(")
Button(action: {
self.contentModel.addPickerData()
self.showPicker.toggle()
}) {
Text("Add Picker Data")
}
Button(action: {
self.showPicker.toggle()
}) {
Text("Show / Hide 2nd Picker")
}
if (showPicker) {
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}else{
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}
Text("Selected Value: \(selectedPickerValue)")
}
}
}
I have a GitHub repo with this issue. I don't think having two Pickers is a viable solution.
Picker Update Bug GitHub Repo

Is it possible for a NavigationLink to perform an action in addition to navigating to another view?

I'm trying to create a button that not only navigates to another view, but also run a function at the same time. I tried embedding both a NavigationLink and a Button into a Stack, but I'm only able to click on the Button.
ZStack {
NavigationLink(destination: TradeView(trade: trade)) {
TradeButton()
}
Button(action: {
print("Hello world!") //this is the only thing that runs
}) {
TradeButton()
}
}
You can use .simultaneousGesture to do that. The NavigationLink will navigate and at the same time perform an action exactly like you want:
NavigationLink(destination: TradeView(trade: trade)) {
Text("Trade View Link")
}.simultaneousGesture(TapGesture().onEnded{
print("Hello world!")
})
You can use NavigationLink(destination:isActive:label:). Use the setter on the binding to know when the link is tapped. I've noticed that the NavigationLink could be tapped outside of the content area, and this approach captures those taps as well.
struct Sidebar: View {
#State var isTapped = false
var body: some View {
NavigationLink(destination: ViewToPresent(),
isActive: Binding<Bool>(get: { isTapped },
set: { isTapped = $0; print("Tapped") }),
label: { Text("Link") })
}
}
struct ViewToPresent: View {
var body: some View {
print("View Presented")
return Text("View Presented")
}
}
The only thing I notice is that setter fires three times, one of which is after it's presented. Here's the output:
Tapped
Tapped
View Presented
Tapped
NavigationLink + isActive + onChange(of:)
// part 1
#State private var isPushed = false
// part 2
NavigationLink(destination: EmptyView(), isActive: $isPushed, label: {
Text("")
})
// part 3
.onChange(of: isPushed) { (newValue) in
if newValue {
// do what you want
}
}
This works for me atm:
#State private var isActive = false
NavigationLink(destination: MyView(), isActive: $isActive) {
Button {
// run your code
// then set
isActive = true
} label: {
Text("My Link")
}
}
Use NavigationLink(_:destination:tag:selection:) initializer and pass your model's property as a selection parameter. Because it is a two-way binding, you can define didset observer for this property, and call your function there.
struct ContentView: View {
#EnvironmentObject var navigationModel: NavigationModel
var body: some View {
NavigationView {
List(0 ..< 10, id: \.self) { row in
NavigationLink(destination: DetailView(id: row),
tag: row,
selection: self.$navigationModel.linkSelection) {
Text("Link \(row)")
}
}
}
}
}
struct DetailView: View {
var id: Int;
var body: some View {
Text("DetailView\(id)")
}
}
class NavigationModel: ObservableObject {
#Published var linkSelection: Int? = nil {
didSet {
if let linkSelection = linkSelection {
// action
print("selected: \(String(describing: linkSelection))")
}
}
}
}
It this example you need to pass in your model to ContentView as an environment object:
ContentView().environmentObject(NavigationModel())
in the SceneDelegate and SwiftUI Previews.
The model conforms to ObservableObject protocol and the property must have a #Published attribute.
(it works within a List)
I also just used:
NavigationLink(destination: View()....) {
Text("Demo")
}.task { do your stuff here }
iOS 15.3 deployment target.

.sheet: Shows only once and then never again

Working with Beta4, it seems that the bug is still existing. The following sequence of views (a list, where a tap on a list entry opens another list) allows to present the ListView exactly once; the onDisappear is never called, so the showModal flag changes, but does not triggers the redisplay of ListView when tapped again. So, for each GridCellBodyEntry, the .sheet presentation works exactly once, and then never again.
I tried around with several suggestions and workarounds, but none worked (e.g., encapsulating with a NavigationViewModel). I even tried to remove the List, because there was an assumption that the List causes that behaviour, but even this did not change anything.
Are there any ideas around?
The setup:
A GridCellBody with this view:
var body: some View {
GeometryReader { geometry in
VStack {
List {
Section(footer: self.footerView) {
ForEach(self.rawEntries) { rawEntry in
GridCellBodyEntry(entityType: rawEntry)
}
}
}
.background(Color.white)
}
}
}
A GridCellBodyEntry with this definition:
struct GridCellBodyEntry: View {
let entityType: EntityType
let viewModel: BaseViewModel
init(entityType: EntityType) {
self.entityType = entityType
self.viewModel = BaseViewModel(entityType: self.entityType)
}
#State var showModal = false {
didSet {
print("showModal: \(showModal)")
}
}
var body: some View {
Group {
Button(action: {
self.showModal.toggle()
},
label: {
Text(entityType.localizedPlural ?? "")
.foregroundColor(Color.black)
})
.sheet(isPresented: $showModal, content: {
ListView(showModal: self.$showModal,
viewModel: self.viewModel)
})
}.onAppear{
print("Profile appeared")
}.onDisappear{
print("Profile disappeared")
}
}
}
A ListView with this definition:
struct ListView: View {
// MARK: - Private properties
// MARK: - Public interface
#Binding var showModal: Bool
#ObjectBinding var viewModel: BaseViewModel
// MARK: - Main view
var body: some View {
NavigationView {
VStack {
List {
Section(footer: Text("\(viewModel.list.count) entries")) {
ForEach(viewModel.list, id: \.objectID) { item in
NavigationLink(destination: ItemView(),
label: {
Text("\(item.objectID)")
})
}
}
}
}
.navigationBarItems(leading:
Button(action: {
self.showModal = false
}, label: {
Text("Close")
}))
.navigationBarTitle(Text(viewModel.entityType.localizedPlural ?? ""))
}
}
}
The BaseViewModel (excerpt):
class BaseViewModel: BindableObject {
/// The binding support.
var willChange = PassthroughSubject<Void, Never>()
/// The context.
var context: NSManagedObjectContext
/// The current list of typed items.
var list: [NSManagedObject] = []
// ... other stuff ...
}
where willChange.send() is called whenever something changes (create, modify, delete operations).
This is a variant of swiftUI PresentaionLink does not work second time
The following simplified code exhibits the behavior you're experiencing (the sheet only displays once):
import SwiftUI
struct ContentView: View {
#State var isPresented = false
#State var whichPresented = -1
var body: some View {
NavigationView {
List {
ForEach(0 ..< 10) { i in
Button(action: {
self.whichPresented = i
self.isPresented.toggle()
})
{ Text("Button \(i)") }
}.sheet(isPresented: $isPresented, content: {
Text("Destination View \(self.whichPresented)") })
}
}
}
}
There appears to be a bug in SwiftUI when you put the .sheet inside a List or a ForEach. If you move the .sheet outside of the List, you should be able to get the correct behavior.
import SwiftUI
struct ContentView: View {
#State var isPresented = false
#State var whichPresented = -1
var body: some View {
NavigationView {
List {
ForEach(0 ..< 10) { i in
Button(action: {
self.whichPresented = i
self.isPresented.toggle()
})
{ Text("Button \(i)") }
}
}
}.sheet(isPresented: $isPresented, content: { Text("Destination View \(self.whichPresented)") })
}
}