SwiftUI Index out of range in ForEach - swift

After hours of debugging I figured out the error is inside the foreach loop in MenuItemView in the folder ContentViews.
The app crashes and the error is:
Fatal error: Index out of range: file Swift/ContiguousArrayBuffer.swift, line 444.
Information:
I have got an ObservableObject with an Array of Structs inside as data storage.
The problem:
The ForEach goes between 0 and the array count + 1. This is so I can have an extra item for adding new elements. In the ForEach is a check if the index is inside the bounds (if (idx >= palettesOO.palettes.count) then show the plus).
But it crashes when I right click any cell and click "Remove". This calls the function RemovePalette in the class Manager. There the data gets removed from the array inside the ObservableObject - this also works.
AFTER the function gets called the app crashes (I know this because I printed a message after the function call). I figured out that the crash occurs when the view gets redrawn (updated).
If I have a view element which does not need a binding, for example a Text, then it works, if it needs a binding, for example a TextField it crashes. Text(palettesOO.palettes[idx].palName) inside of the else inside the ForEach works but view elements or subviews which require Bindings do not work: TextField("", text: $palettesOO.palettes[idx].palName) crashes.
I have tried modifying the ForEach with things like these but with no success.
The Code and Data:
class PalettesOO: ObservableObject {
#Published var palettes = [Palette]()
}
MenuItemView:
struct MenuItemView: View {
#ObservedObject var palettesOO = PalettesOO()
var body: some View {
VStack {
SectionView("Palettes") {
LazyVGrid(columns: Array(repeating: GridItem(.fixed(viewCellSize), spacing: viewCellSpacing), count: viewColCount), spacing: viewCellSpacing) {
ForEach(0..<palettesOO.palettes.count + 1, id: \.self) { idx in
if (idx >= palettesOO.palettes.count) {
Button(action: {
newPalettePopover = true
}, label: {
Image(systemName: "plus.square").font(.system(size: viewCellSize))
}).buttonStyle(PlainButtonStyle())
}
else {
// Works
Text(palettesOO.palettes[idx].palName)
// Does not work
TextField("ASD", text: $palettesOO.palettes[palettesOO.palettes.count - 1].palName).frame(width: 100, height: 100).background(Color.red).contextMenu(ContextMenu(menuItems: {
Button(action: {}, label: {
Text("Rename")
})
Button(action: { Manager.RemovePalette(name: palettesOO.palettes[idx].palName); print("Len \(palettesOO.palettes.count)") }, label: {
Text("Delete")
})
}))
// Original code, also crashes (PalettePreviewView is a custom subview which does not matter for this)
// PalettePreviewView(palette: $palettesOO.palettes[palettesOO.palettes.count - 1], colNum: $previewColCount, cellSize: $viewCellSize).cornerRadius(viewCellSize / 100 * viewCellRadius).contextMenu(ContextMenu(menuItems: {
// Button(action: {}, label: {
// Text("Rename")
// })
// Button(action: { Manager.RemovePalette(name: palettesOO.palettes[idx].palName); print("Len \(palettesOO.palettes.count)") }, label: {
// Text("Delete")
// })
// }))
}
}
}
}
}.padding().fixedSize()
}
}
Manager:
class Manager {
static func RemovePalette(name: String) {
var url = assetFilesDirectory(name: "Palettes", shouldCreate: true)
url?.appendPathComponent("\(name).json")
if (url == nil) {
return
}
do {
try FileManager.default.removeItem(at: url!)
} catch let error as NSError {
print("Error: \(error.domain)")
}
LoadAllPalettes()
UserDefaults.standard.removeObject(forKey: "\(k_paletteIndicies).\(name)")
}
}
I know that such complex problems are not good to post on Stack Overflow but I can't think of any other way.
The project version control is public on my GitHub, in case it's needed to find a solution.
EDIT 12/21/2020 # 8:30pm:
Thanks to #SHS it now works like a charm!
Here is the final working code:
struct MenuItemView: View {
#ObservedObject var palettesOO = PalettesOO()
var body: some View {
VStack {
...
ForEach(0..<palettesOO.palettes.count + 1, id: \.self) { idx in
...
//// #SHS Changed :-
Safe(self.$palettesOO.palettes, index: idx) { binding in
TextField("ASD", text: binding.palName).frame(width: 100, height: 100).background(Color.red).contextMenu(ContextMenu(menuItems: {
Button(action: {}, label: {
Text("Rename")
})
Button(action: { Manager.RemovePalette(name: binding.wrappedValue.palName); print("Len \(palettesOO.palettes.count)") }, label: {
Text("Delete")
})
}))
}
}
}
...
}
}
//// #SHS Added :-
//// You may keep the following structure in different file or Utility folder. You may rename it properly.
struct Safe<T: RandomAccessCollection & MutableCollection, C: View>: View {
typealias BoundElement = Binding<T.Element>
private let binding: BoundElement
private let content: (BoundElement) -> C
init(_ binding: Binding<T>, index: T.Index, #ViewBuilder content: #escaping (BoundElement) -> C) {
self.content = content
self.binding = .init(get: { binding.wrappedValue[index] },
set: { binding.wrappedValue[index] = $0 })
}
var body: some View {
content(binding)
}
}

As per Answer at stackoverflow link
Create a struct as under
struct Safe<T: RandomAccessCollection & MutableCollection, C: View>: View {
typealias BoundElement = Binding<T.Element>
private let binding: BoundElement
private let content: (BoundElement) -> C
init(_ binding: Binding<T>, index: T.Index, #ViewBuilder content: #escaping (BoundElement) -> C) {
self.content = content
self.binding = .init(get: { binding.wrappedValue[index] },
set: { binding.wrappedValue[index] = $0 })
}
var body: some View {
content(binding)
}
}
Then wrap your code for accessing it as under
Safe(self.$palettesOO.palettes, index: idx) { binding in
//Text(binding.wrappedValue.palName)
TextField("ASD", text: binding.palName)
//TextField("ASD", text: $palettesOO.palettes[palettesOO.palettes.count - 1].palName)
.frame(width: 100, height: 100).background(Color.red)
.contextMenu(ContextMenu(menuItems: {
Button(action: {}, label: {
Text("Rename")
})
Button(action: { Manager.RemovePalette(name: binding.wrappedValue.palName); print("Len \(palettesOO.palettes.count)") }, label: {
Text("Delete")
})
}))
}
I hope this can help you ( till it is corrected in Swift )

Related

SwiftUI: NavigationLink weird behaviour

I'm trying to build a store in order to navigate in a SwifUI flow.
The idea is that every screen should observe the state and push into the next one using a NavigationLink.
It seems to work for pushing one view, but a soon as I push several views into the stack, it starts behaving oddly: the view pops itself.
Luckily I was able to reproduce it in a separate project. When I move from 2 to 3, the third screen appears but the NavigationView resets to it's original state (ContentView):
import SwiftUI
#main
struct NavigationTestApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
}.navigationViewStyle(StackNavigationViewStyle())
}
}
}
class Store: ObservableObject{
#Published var state: StoreState
struct StoreState {
var flowState: FlowState = .none
}
enum FlowState {
case none, one, two, three
}
enum Action {
case moveTo1, moveTo2, moveTo3
}
init(state: StoreState) {
self.state = state
}
func send(_ action: Action) {
switch action {
case .moveTo1:
state.flowState = .one
case .moveTo2:
state.flowState = .two
case .moveTo3:
state.flowState = .three
}
}
}
struct ContentView: View {
#ObservedObject var store = Store(state: .init())
var body: some View {
VStack {
Button(action: {
store.send(.moveTo1)
}, label: {
Text("moveTo1")
})
NavigationLink(
destination: ContentView1().environmentObject(store),
isActive: Binding(
get: { store.state.flowState == .one },
set: { _ in
}
),
label: {}
)
}
}
}
struct ContentView1: View {
#EnvironmentObject var store: Store
var body: some View {
VStack {
Button(action: {
store.send(.moveTo2)
}, label: {
Text("moveTo2")
})
NavigationLink(
destination: ContentView2().environmentObject(store),
isActive: Binding(
get: { store.state.flowState == .two },
set: { _ in
}
),
label: {}
)
}
}
}
struct ContentView2: View {
#EnvironmentObject var store: Store
var body: some View {
VStack {
Button(action: {
store.send(.moveTo3)
}, label: {
Text("moveTo3")
})
NavigationLink(
destination: ContentView3().environmentObject(store),
isActive: Binding(
get: { store.state.flowState == .three },
set: { _ in
}
),
label: {}
)
}
}
}
struct ContentView3: View {
#EnvironmentObject var store: Store
var body: some View {
Text("Hello, world!")
.padding()
}
}
What am I missing?
I figured out a solution. The issue seems to be that your model pops the previous view from the stack when you "move to" the next. I modified it (slightly) so that the system now can determine which views need to be on the navigation stack.
I have to admit, though, that I would have expected that your example would work, since we are just declaring which (single) view is on the stack.
So, the slightly modified code which keeps the previous views on the navigation stack (I also sprinkled in some print statements to see what's going on):
import SwiftUI
class Store: ObservableObject{
#Published var state: StoreState
struct StoreState {
var flowState: FlowState = .none
}
enum FlowState: Int {
case none = 0, one, two, three
}
enum Action {
case moveTo1, moveTo2, moveTo3
}
init(state: StoreState) {
self.state = state
}
func send(_ action: Action) {
print("state: \(state), action: \(action)")
switch action {
case .moveTo1:
state.flowState = .one
case .moveTo2:
state.flowState = .two
case .moveTo3:
state.flowState = .three
}
print("new state: \(state)")
}
}
struct ContentView: View {
#ObservedObject var store = Store(state: .init())
var body: some View {
VStack {
Button(action: {
print("moveTo1")
store.send(.moveTo1)
}, label: {
Text("moveTo1")
})
NavigationLink(
destination: ContentView1().environmentObject(store),
isActive: Binding(
get: { store.state.flowState.rawValue >= Store.FlowState.one.rawValue },
set: { newValue in
store.state.flowState = .none
print("ContentView1: \(newValue)")
}
),
label: {}
)
}
}
}
struct ContentView1: View {
#EnvironmentObject var store: Store
var body: some View {
VStack {
Button(action: {
print("moveTo2")
store.send(.moveTo2)
}, label: {
Text("moveTo2")
})
NavigationLink(
destination: ContentView2().environmentObject(store),
isActive: Binding(
get: { store.state.flowState.rawValue >= Store.FlowState.two.rawValue },
set: { newValue in
store.state.flowState = .one
print("ContentView2: \(newValue)")
}
),
label: {}
)
}
}
}
struct ContentView2: View {
#EnvironmentObject var store: Store
var body: some View {
VStack {
Button(action: {
print("moveTo3")
store.send(.moveTo3)
}, label: {
Text("moveTo3")
})
NavigationLink(
destination: ContentView3().environmentObject(store),
isActive: Binding(
get: { store.state.flowState.rawValue >= Store.FlowState.three.rawValue },
set: { newValue in
store.state.flowState = .two
print("ContentView3: \(newValue)")
}
),
label: {}
)
}
}
}
struct ContentView3: View {
#EnvironmentObject var store: Store
var body: some View {
Text("Hello, world!")
.padding()
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(
NavigationView {
ContentView()
}.navigationViewStyle(StackNavigationViewStyle())
)
Notes:
It will be probably better to add an event (aka action) back and determine the new state in the view model, instead letting it doing from the view.

SwiftUI - ForEach deletion transition always applied to last item only

I'm trying to add a delete animation to my ForEach, so that each Card inside it scales out when removed. This is what I have so far:
The problem is that no matter which Card is pressed, it's always the last one that animates. And sometimes, the text inside each card has a weird sliding/morphing animation. Here's my code:
/// Ran into this problem: "SwiftUI ForEach index out of range error when removing row"
/// `ObservableObject` solution from https://stackoverflow.com/a/62796050/14351818
class Card: ObservableObject, Identifiable {
let id = UUID()
#Published var name: String
init(name: String) {
self.name = name
}
}
struct ContentView: View {
#State var cards = [
Card(name: "Apple"),
Card(name: "Banana "),
Card(name: "Coupon"),
Card(name: "Dog"),
Card(name: "Eat")
]
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(cards.indices, id: \.self) { index in
CardView(card: cards[index], removePressed: {
withAnimation(.easeOut) {
_ = cards.remove(at: index) /// remove the card
}
})
.transition(.scale)
}
}
}
}
}
struct CardView: View {
#ObservedObject var card: Card
var removePressed: (() -> Void)?
var body: some View {
Button(action: {
removePressed?() /// call the remove closure
}) {
VStack {
Text("Remove")
Text(card.name)
}
}
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .medium))
.padding(40)
.background(Color.red)
}
}
How can I scale out the Card that is clicked, and not the last one?
The reason you're seeing this behavior is because you use an index as an id for ForEach. So, when an element is removed from the cards array, the only difference that ForEach sees is that the last index is gone.
You need to make sure that the id uniquely identifies each element of ForEach.
If you must use indices and have each element identified, you can either use the enumerated method or zip the array and its indices together. I like the latter:
ForEach(Array(zip(cards.indices, cards)), id: \.1) { (index, card) in
//...
}
The above uses the object itself as the ID, which requires conformance to Hashable. If you don't want that, you can use the id property directly:
ForEach(Array(zip(cards.indices, cards)), id: \.1.id) { (index, card) in
//...
}
For completeness, here's the enumerated version (technically, it's not an index, but rather an offset, but for 0-based arrays it's the same):
ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in
//...
}
New Dev's answer was great, but I had something else that I needed. In my full code, I had a button inside each Card that scrolled the ScrollView to the end.
/// the ForEach
ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in
CardView(
card: cards[index],
scrollToEndPressed: {
proxy.scrollTo(cards.count - 1, anchor: .center) /// trying to scroll to end... not working though.
},
removePressed: {
withAnimation(.easeOut) {
_ = cards.remove(at: index) /// remove the card
}
}
)
.transition(.scale)
}
/// CardView
struct CardView: View {
#ObservedObject var card: Card
var scrollToEndPressed: (() -> Void)?
var removePressed: (() -> Void)?
var body: some View {
VStack {
Button(action: {
scrollToEndPressed?() /// scroll to the end
}) {
VStack {
Text("Scroll to end")
}
}
Button(action: {
removePressed?() /// call the remove closure
}) {
VStack {
Text("Remove")
Text(card.name)
}
}
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .medium))
.padding(40)
.background(Color.red)
}
}
}
With the above code, the "Scroll to end" button didn't work.
I fixed this by assigning an explicit ID to each CardView.
ForEach(Array(cards.enumerated()), id: \.1) { (index, card) in
CardView(card: cards[index], scrollToEndPressed: {
withAnimation(.easeOut) { /// also animate it
proxy.scrollTo(cards.last?.id ?? card.id, anchor: .center) /// scroll to the last card's ID
}
}, removePressed: {
withAnimation(.easeOut) {
_ = cards.remove(at: index) /// remove the card
}
})
.id(card.id) /// add ID
.transition(.scale)
}
Result:
I recommend you to rethink and use Card as struct rather than a class and confirm to Identifiable and Equatable.
struct Card: Hashable, Identifiable {
let id = UUID()
var name: String
init(name: String) {
self.name = name
}
}
And then create a View Model that holds your cards.
class CardViewModel: ObservableObject {
#Published
var cards: [Card] = [
Card(name: "Apple"),
Card(name: "Banana "),
Card(name: "Coupon"),
Card(name: "Dog"),
Card(name: "Eat")
]
}
Iterate over cardViewModel.cards and pass card to the CardView. Use removeAll method of Array instead of remove. It is safe since Cards are unique.
ForEach(viewModel.cards) { card in
CardView(card: card) {
withAnimation(.easeOut) {
cardViewModel.cards.removeAll { $0 == card}
}
}
}
A complete working exammple.
struct ContentView: View {
#ObservedObject var cardViewModel = CardViewModel()
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(cardViewModel.cards) { card in
CardView(card: card) {
withAnimation(.easeOut) {
cardViewModel.cards.removeAll { $0 == card}
}
}
.transition(.scale)
}
}
}
}
}
struct CardView: View {
var card: Card
var removePressed: (() -> Void)?
var body: some View {
Button(action: {
removePressed?()
}) {
VStack {
Text("Remove")
Text(card.name)
}
}
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .medium))
.padding(40)
.background(Color.red)
}
}
If for some reason you need index of card in ContentView, do this.
Accessing and manipulating array item in an EnvironmentObject
Removing items from a child view generated by For Each loop causes Fatal Error
Both of them are similar to this tutorial of Apple.
https://developer.apple.com/tutorials/swiftui/handling-user-input

SwiftUI ObservedObject in View has two references (instances) BUG

I do not why but I have very frustrating bug in my SwiftUI view.
This view has reference to ViewModel object. But this View is created multiple times on screen appear, and at the end the single View have multiple references to ViewModel object.
I reference this view model object in custom Binding setter/getter or in closure. But object references in Binding and in closure are totally different. This causes many problems with proper View refreshing or saving changes.
struct DealDetailsStagePicker : View {
// MARK: - Observed
#ObservedObject var viewModel: DealDetailsStageViewModel
// MARK: - State
/// TODO: It is workaround as viewModel.dealStageId doesn't work correctly
/// viewModel object is instantiated several times and pickerBinding and onDone
/// closure has different references to viewModel object
/// so updating dealStageId via pickerBinding refreshes it in different viewModel
/// instance than onDone closure executed changeDealStage() method (where dealStageId
/// property stays with initial or nil value.
#State var dealStageId: String? = nil
// MARK: - Binding
#Binding private var showPicker: Bool
// MARK: - Properties
let deal : Deal
// MARK: - Init
init(deal: Deal, showPicker: Binding<Bool>) {
self.deal = deal
self._showPicker = showPicker
self.viewModel = DealDetailsStageViewModel(dealId: deal.id!)
}
var body: some View {
let pickerBinding = Binding<String>(get: {
if self.viewModel.dealStageId == nil {
self.viewModel.dealStageId = self.dealStage?.id ?? ""
}
return self.viewModel.dealStageId!
}, set: { id in
self.viewModel.dealStageId = id //THIS viewModel is reference to object 0x8784783
self.dealStageId = id
})
return VStack(alignment: .leading, spacing: 4) {
Text("Stage".uppercased())
Button(action: {
self.showPicker = true
}) {
HStack {
Text("\(deal.status ?? "")")
Image(systemName: "chevron.down")
}
.contentShape(Rectangle())
}
}
.buttonStyle(BorderlessButtonStyle())
.adaptivePicker(isPresented: $showPicker, selection: pickerBinding, popoverSize: CGSize(width: 400, height: 200), popoverArrowDirection: .up, onDone: {
// save change
self.viewModel.changeDealStage(self.dealStages, self.dealStageId) // THIS viewModel references 0x92392983
}) {
ForEach(self.dealStages, id: \.id) { stage in
Text(stage.name)
.foregroundColor(Color("Black"))
}
}
}
}
I am experiencing this problem in multiple places writing SwiftUI code.
I have several workarounds:
1) as you can see here I use additional #State var to store dealStageId and pass it to viewModale.changeDealStage() instead of updating it on viewModal
2) in other places I am using Wrapper View around such view, then add #State var viewModel: SomeViewModel, then pass this viewModel and assign to #ObservedObject.
But this errors happens randomly depending on placement this View as Subview of other Views. Sometimes it works, sometime it does not work.
It is very od that SINGLE view can have references to multiple view models even if it is instantiated multiple times.
Maybe the problem is with closure as it keeps reference to first ViewModel instance and then this closure is not refreshed in adaptivePicker view modifier?
Workarounds around this issue needs many debugging and boilerplate code to write!
Anyone can help what I am doing wrong or what is wrong with SwiftUI/ObservableObject?
UPDATE
Here is the usage of this View:
private func makeDealHeader() -> some View {
VStack(spacing: 10) {
Spacer()
VStack(spacing: 4) {
Text(self.deal?.name ?? "")
Text(NumberFormatter.price.string(from: NSNumber(value: Double(self.deal?.amount ?? 0)/100.0))!)
}.frame(width: UIScreen.main.bounds.width*0.667)
HStack {
if deal != nil {
DealDetailsStagePicker(deal: self.deal!, showPicker: self.$showStagePicker)
}
Spacer(minLength: 24)
if deal != nil {
DealDetailsClientPicker(deal: self.deal!, showPicker: self.$showClientPicker)
}
}
.padding(.horizontal, 24)
self.makeDealIcons()
Spacer()
}
.frame(maxWidth: .infinity)
.listRowInsets(EdgeInsets(top: 0.0, leading: 0.0, bottom: 0.0, trailing: 0.0))
}
var body: some View {
ZStack {
Color("White").edgesIgnoringSafeArea(.all)
VStack {
self.makeNavigationLink()
List {
self.makeDealHeader()
Section(header: self.makeSegmentedControl()) {
self.makeSection()
}
}
....
UPDATE 2
Here is adaptivePicker
extension View {
func adaptivePicker<Data, ID, Content>(isPresented: Binding<Bool>, selection: Binding<ID>, popoverSize: CGSize? = nil, popoverArrowDirection: UIPopoverArrowDirection = .any, onDone: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> ForEach<Data, ID, Content>) -> some View where Data : RandomAccessCollection, ID: Hashable, Content: View {
self.modifier(AdaptivePicker2(isPresented: isPresented, selection: selection, popoverSize: popoverSize, popoverArrowDirection: popoverArrowDirection, onDone: onDone, content: content))
}
and here is AdaptivePicker2 view modifier implementation
struct AdaptivePicker2<Data, ID, RowContent> : ViewModifier, OrientationAdjustable where Data : RandomAccessCollection, ID: Hashable , RowContent: View {
// MARK: - Environment
#Environment(\.verticalSizeClass) var _verticalSizeClass
var verticalSizeClass: UserInterfaceSizeClass? {
_verticalSizeClass
}
// MARK: - Binding
private var isPresented: Binding<Bool>
private var selection: Binding<ID>
// MARK: - State
#State private var showPicker : Bool = false
// MARK: - Actions
private let onDone: (() -> Void)?
// MARK: - Properties
private let popoverSize: CGSize?
private let popoverArrowDirection: UIPopoverArrowDirection
private let pickerContent: () -> ForEach<Data, ID, RowContent>
// MARK: - Init
init(isPresented: Binding<Bool>, selection: Binding<ID>, popoverSize: CGSize? = nil, popoverArrowDirection: UIPopoverArrowDirection = .any, onDone: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> ForEach<Data, ID, RowContent>) {
self.isPresented = isPresented
self.selection = selection
self.popoverSize = popoverSize
self.popoverArrowDirection = popoverArrowDirection
self.onDone = onDone
self.pickerContent = content
}
var pickerView: some View {
Picker("Select State", selection: self.selection) {
self.pickerContent()
}
.pickerStyle(WheelPickerStyle())
.labelsHidden()
}
func body(content: Content) -> some View {
let isShowingBinding = Binding<Bool>(get: {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withAnimation {
self.showPicker = self.isPresented.wrappedValue
}
}
return self.isPresented.wrappedValue
}, set: {
self.isPresented.wrappedValue = $0
})
let popoverBinding = Binding<Bool>(get: {
self.isPresented.wrappedValue
}, set: {
self.onDone?()
self.isPresented.wrappedValue = $0
})
return Group {
if DeviceType.IS_ANY_IPAD {
if self.popoverSize != nil {
content.presentPopover(isShowing: popoverBinding, popoverSize: popoverSize, arrowDirection: popoverArrowDirection) { self.pickerView }
} else {
content.popover(isPresented: popoverBinding) { self.pickerView }
}
} else {
content.present(isShowing: isShowingBinding) {
ZStack {
Color("Dim")
.opacity(0.25)
.transition(.opacity)
.onTapGesture {
self.isPresented.wrappedValue = false
self.onDone?()
}
VStack {
Spacer()
// TEST: Text("Show Picker: \(self.showPicker ? "True" : "False")")
if self.showPicker {
VStack {
Divider().background(Color.white)
.shadow(color: Color("Dim"), radius: 4)
HStack {
Spacer()
Button("Done") {
print("Tapped picker done button!")
self.isPresented.wrappedValue = false
self.onDone?()
}
.foregroundColor(Color("Accent"))
.padding(.trailing, 16)
}
self.pickerView
.frame(height: self.isLandscape ? 120 : nil)
}
.background(Color.white)
.transition(.move(edge: .bottom))
.animation(.easeInOut(duration: 0.35))
}
}
}
.edgesIgnoringSafeArea(.all)
}
}
}
}
}
It seems new #StateObject from iOS 14 will solve this issue in SwiftUI.

How to make List with single selection with SwiftUI

I am creating single selection list to use in different places in my app.
Questions:
Is there an easy solution I don't know?
If there isn't, how can I finish my current solution?
My goal:
List with always only one item selected or one or none item selected (depending on configuration)
Transparent background
On item select - perform action which is set as parameter via init() method. (That action requires selected item info.)
Change list data programmatically and reset selection to first item
My current solution looks like:
List view with second item selected
I can't use Picker, because outer action (goal Nr. 3) is time consuming. So I think it wouldn't work smoothly.
Most probably there is solution to my problem in SwiftUI, but either I missed it, because I am new to swift or as I understand not everything works perfectly in SwiftUI yet, for example: transparent background for List (which is why i needed to clear background in init()).
So I started implementing selection myself, and stoped here:
My current solution does not update view when item(Button) is clicked. (Only going out and back to the page updates view). And still multiple items can be selected.
import SwiftUI
struct ModuleList: View {
var modules: [Module] = []
#Binding var selectionKeeper: Int
var Action: () -> Void
init(list: [Module], selection: Binding<Int>, action: #escaping () -> Void) {
UITableView.appearance().backgroundColor = .clear
self.modules = list
self._selectionKeeper = selection
self.Action = action
}
var body: some View {
List(){
ForEach(0..<modules.count) { i in
ModuleCell(module: self.modules[i], action: { self.changeSelection(index: i) })
}
}.background(Constants.colorTransparent)
}
func changeSelection(index: Int){
modules[selectionKeeper].isSelected = false
modules[index].isSelected = true
selectionKeeper = index
self.Action()
}
}
struct ModuleCell: View {
var module: Module
var Action: () -> Void
init(module: Module, action: #escaping () -> Void) {
UITableViewCell.appearance().backgroundColor = .clear
self.module = module
self.Action = action
}
var body: some View {
Button(module.name, action: {
self.Action()
})
.frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
.modifier(Constants.CellSelection(isSelected: module.isSelected))
}
}
class Module: Identifiable {
var id = UUID()
var name: String = ""
var isSelected: Bool = false
var address: Int
init(name: String, address: Int){
self.name = name
self.address = address
}
}
let testLines = [
Module(name: "Line1", address: 1),
Module(name: "Line2", address: 3),
Module(name: "Line3", address: 5),
Module(name: "Line4", address: 6),
Module(name: "Line5", address: 7),
Module(name: "Line6", address: 8),
Module(name: "Line7", address: 12),
Module(name: "Line8", address: 14),
Module(name: "Line9", address: 11),
Module(name: "Line10", address: 9),
Module(name: "Line11", address: 22)
]
Testing some ideas:
Tried adding #State array of (isSelected: Bool) in ModuleList and binding it to Module isSelected parameter that MIGHT update view... But failed then populating this array in init(), because #State array parameter would stay empty after .append()... Maybe adding function setList would have solved this, and my goal Nr. 4. But I was not sure if this would really update my view in the first place.
struct ModuleList: View {
var modules: [Module] = []
#State var selections: [Bool] = []
init(list: [String]) {
UITableView.appearance().backgroundColor = .clear
selections = [Bool] (repeating: false, count: list.count) // stays empty
let test = [Bool] (repeating: false, count: list.count) // testing: works as it should
selections = test
for i in 0..<test.count { // for i in 0..<selections.count {
selections.append(false)
modules.append(Module(name: list[i], isSelected: $selections[i])) // Error selections is empty
}
}
var body: some View {
List{
ForEach(0..<modules.count) { i in
ModuleCell(module: self.modules[i], action: { self.changeSelection(index: i) })
}
}.background(Constants.colorTransparent)
}
func changeSelection(index: Int){
modules[index].isSelected = true
}
}
struct ModuleCell: View {
var module: Module
var Method: () -> Void
init(module: Module, action: #escaping () -> Void) {
UITableViewCell.appearance().backgroundColor = .clear
self.module = module
self.Method = action
}
var body: some View {
Button(module.name, action: {
self.Method()
})
.frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
.modifier(Constants.CellSelection(isSelected: module.isSelected))
}
}
struct Module: Identifiable {
var id = UUID()
var name: String = ""
#Binding var isSelected: Bool
init(name: String, isSelected: Binding<Bool>){
self.name = name
self._isSelected = isSelected
}
}
let testLines = ["Line1","Line2","Line3","Line4"
]
The easiest way to achieve this would be to have #State in the View containing the list with the selection and pass it as #Binding to the cells:
struct SelectionView: View {
let fruit = ["apples", "pears", "bananas", "pineapples"]
#State var selectedFruit: String? = nil
var body: some View {
List {
ForEach(fruit, id: \.self) { item in
SelectionCell(fruit: item, selectedFruit: self.$selectedFruit)
}
}
}
}
struct SelectionCell: View {
let fruit: String
#Binding var selectedFruit: String?
var body: some View {
HStack {
Text(fruit)
Spacer()
if fruit == selectedFruit {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
} .onTapGesture {
self.selectedFruit = self.fruit
}
}
}
Here is a more generic approach, you can still extend answer according to your needs;
TLDR
https://gist.github.com/EnesKaraosman/d778cdabc98ca269b3d162896bea8aac
Detail
struct SingleSelectionList<Item: Identifiable, Content: View>: View {
var items: [Item]
#Binding var selectedItem: Item?
var rowContent: (Item) -> Content
var body: some View {
List(items) { item in
rowContent(item)
.modifier(CheckmarkModifier(checked: item.id == self.selectedItem?.id))
.contentShape(Rectangle())
.onTapGesture {
self.selectedItem = item
}
}
}
}
struct CheckmarkModifier: ViewModifier {
var checked: Bool = false
func body(content: Content) -> some View {
Group {
if checked {
ZStack(alignment: .trailing) {
content
Image(systemName: "checkmark")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.green)
.shadow(radius: 1)
}
} else {
content
}
}
}
}
And to demonstrate;
struct PlaygroundView: View {
struct Koko: Identifiable {
let id = UUID().uuidString
var name: String
}
var mock = Array(0...10).map { Koko(name: "Item - \($0)") }
#State var selectedItem: Koko?
var body: some View {
VStack {
Text("Selected Item: \(selectedItem?.name ?? "Select one")")
Divider()
SingleSelectionList(items: mock, selectedItem: $selectedItem) { (item) in
HStack {
Text(item.name)
Spacer()
}
}
}
}
}
Final Result
Selection
SwiftUI does not currently have a built in way to select one row of a list and change its appearance accordingly. But you're actually very close to your answer. In fact, your selection is in fact already working, but just isn't being used in any way.
To illustrate, add the following line right after ModuleCell(...) in your ForEach:
.background(i == self.selectionKeeper ? Color.red : nil)
In other words, "If my current row (i) matches the value stored in selectionKeeper, color the cell red, otherwise, use the default color." You'll see that as you tap on different rows, the red coloring follows your taps, showing the underlying selection is in fact changing.
Deselection
If you wanted to enable deselection, you could pass in a Binding<Int?> as your selection, and set it to nil when the currently selected row is tapped:
struct ModuleList: View {
var modules: [Module] = []
// this is new ------------------v
#Binding var selectionKeeper: Int?
var Action: () -> Void
// this is new ----------------------------v
init(list: [Module], selection: Binding<Int?>, action: #escaping () -> Void) {
...
func changeSelection(index: Int){
if selectionKeeper != index {
selectionKeeper = index
} else {
selectionKeeper = nil
}
self.Action()
}
}
Deduplicating State and Separation of Concerns
On a more structural level, you really want a single source of truth when using a declarative UI framework like SwiftUI, and to cleanly separate your view from your model. At present, you have duplicated state — selectionKeeper in ModuleList and isSelected in Module both keep track of whether a given module is selected.
In addition, isSelected should really be a property of your view (ModuleCell), not of your model (Module), because it has to do with how your view appears, not the intrinsic data of each module.
Thus, your ModuleCell should look something like this:
struct ModuleCell: View {
var module: Module
var isSelected: Bool // Added this
var Action: () -> Void
// Added this -------v
init(module: Module, isSelected: Bool, action: #escaping () -> Void) {
UITableViewCell.appearance().backgroundColor = .clear
self.module = module
self.isSelected = isSelected // Added this
self.Action = action
}
var body: some View {
Button(module.name, action: {
self.Action()
})
.frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
.modifier(Constants.CellSelection(isSelected: isSelected))
// Changed this ------------------------------^
}
}
And your ForEach would look like
ForEach(0..<modules.count) { i in
ModuleCell(module: self.modules[i],
isSelected: i == self.selectionKeeper,
action: { self.changeSelection(index: i) })
}
Use binding with Optional type selection variable.
List will allow only one element selected.
struct ContentView: View {
// Use Optional for selection.
// Instead od Set or Array like this...
// #State var selection = Set<Int>()
#State var selection = Int?.none
var body: some View {
List(selection: $selection) {
ForEach(0..<128) { _ in
Text("Sample")
}
}
}
}
Tested with Xcode Version 11.3 (11C29) on macOS 10.15.2 (19C57) for a Mac app.

SwiftUI: Support multiple modals

I'm trying to setup a view that can display multiple modals depending on which button is tapped.
When I add just one sheet, everything works:
.sheet(isPresented: $showingModal1) { ... }
But when I add another sheet, only the last one works.
.sheet(isPresented: $showingModal1) { ... }
.sheet(isPresented: $showingModal2) { ... }
UPDATE
I tried to get this working, but I'm not sure how to declare the type for modal. I'm getting an error of Protocol 'View' can only be used as a generic constraint because it has Self or associated type requirements.
struct ContentView: View {
#State var modal: View?
var body: some View {
VStack {
Button(action: {
self.modal = ModalContentView1()
}) {
Text("Show Modal 1")
}
Button(action: {
self.modal = ModalContentView2()
}) {
Text("Show Modal 2")
}
}.sheet(item: self.$modal, content: { modal in
return modal
})
}
}
struct ModalContentView1: View {
var body: some View {
Text("Modal 1")
}
}
struct ModalContentView2: View {
var body: some View {
Text("Modal 2")
}
}
This works:
.background(EmptyView().sheet(isPresented: $showingModal1) { ... }
.background(EmptyView().sheet(isPresented: $showingModal2) { ... }))
Notice how these are nested backgrounds. Not two backgrounds one after the other.
Thanks to DevAndArtist for finding this.
Maybe I missed the point, but you can achieve it either with a single call to .sheet(), or multiple calls.:
Multiple .sheet() approach:
import SwiftUI
struct MultipleSheets: View {
#State private var sheet1 = false
#State private var sheet2 = false
#State private var sheet3 = false
var body: some View {
VStack {
Button(action: {
self.sheet1 = true
}, label: { Text("Show Modal #1") })
.sheet(isPresented: $sheet1, content: { Sheet1() })
Button(action: {
self.sheet2 = true
}, label: { Text("Show Modal #2") })
.sheet(isPresented: $sheet2, content: { Sheet2() })
Button(action: {
self.sheet3 = true
}, label: { Text("Show Modal #3") })
.sheet(isPresented: $sheet3, content: { Sheet3() })
}
}
}
struct Sheet1: View {
var body: some View {
Text("This is Sheet #1")
}
}
struct Sheet2: View {
var body: some View {
Text("This is Sheet #2")
}
}
struct Sheet3: View {
var body: some View {
Text("This is Sheet #3")
}
}
Single .sheet() approach:
struct MultipleSheets: View {
#State private var showModal = false
#State private var modalSelection = 1
var body: some View {
VStack {
Button(action: {
self.modalSelection = 1
self.showModal = true
}, label: { Text("Show Modal #1") })
Button(action: {
self.modalSelection = 2
self.showModal = true
}, label: { Text("Show Modal #2") })
Button(action: {
self.modalSelection = 3
self.showModal = true
}, label: { Text("Show Modal #3") })
}
.sheet(isPresented: $showModal, content: {
if self.modalSelection == 1 {
Sheet1()
}
if self.modalSelection == 2 {
Sheet2()
}
if self.modalSelection == 3 {
Sheet3()
}
})
}
}
struct Sheet1: View {
var body: some View {
Text("This is Sheet #1")
}
}
struct Sheet2: View {
var body: some View {
Text("This is Sheet #2")
}
}
struct Sheet3: View {
var body: some View {
Text("This is Sheet #3")
}
}
I'm not sure whether this was always possible, but in Xcode 11.3.1 there is an overload of .sheet() for exactly this use case (https://developer.apple.com/documentation/swiftui/view/3352792-sheet). You can call it with an Identifiable item instead of a bool:
struct ModalA: View {
var body: some View {
Text("Hello, World! (A)")
}
}
struct ModalB: View {
var body: some View {
Text("Hello, World! (B)")
}
}
struct MyContentView: View {
enum Sheet: Hashable, Identifiable {
case a
case b
var id: Int {
return self.hashValue
}
}
#State var activeSheet: Sheet? = nil
var body: some View {
VStack(spacing: 42) {
Button(action: {
self.activeSheet = .a
}) {
Text("Hello, World! (A)")
}
Button(action: {
self.activeSheet = .b
}) {
Text("Hello, World! (B)")
}
}
.sheet(item: $activeSheet) { item in
if item == .a {
ModalA()
} else if item == .b {
ModalB()
}
}
}
}
I personally would mimic some NavigationLink API. Then you can create a hashable enum and decide which modal sheet you want to present.
extension View {
func sheet<Content, Tag>(
tag: Tag,
selection: Binding<Tag?>,
content: #escaping () -> Content
) -> some View where Content: View, Tag: Hashable {
let binding = Binding(
get: {
selection.wrappedValue == tag
},
set: { isPresented in
if isPresented {
selection.wrappedValue = tag
} else {
selection.wrappedValue = .none
}
}
)
return background(EmptyView().sheet(isPresented: binding, content: content))
}
}
enum ActiveSheet: Hashable {
case first
case second
}
struct First: View {
var body: some View {
Text("frist")
}
}
struct Second: View {
var body: some View {
Text("second")
}
}
struct TestView: View {
#State
private var _activeSheet: ActiveSheet?
var body: some View {
print(_activeSheet as Any)
return VStack
{
Button("first") {
self._activeSheet = .first
}
Button("second") {
self._activeSheet = .second
}
}
.sheet(tag: .first, selection: $_activeSheet) {
First()
}
.sheet(tag: .second, selection: $_activeSheet) {
Second()
}
}
}
I wrote a library off plivesey's answer that greatly simplifies the syntax:
.multiSheet {
$0.sheet(isPresented: $sheetAPresented) { Text("Sheet A") }
$0.sheet(isPresented: $sheetBPresented) { Text("Sheet B") }
$0.sheet(isPresented: $sheetCPresented) { Text("Sheet C") }
}
I solved this by creating an observable SheetContext that holds and manages the state. I then only need a single context instance and can tell it to present any view as a sheet. I prefer this to the "active view" binding approach, since you can use this context in multiple ways.
I describe it in more details in this blog post: https://danielsaidi.com/blog/2020/06/06/swiftui-sheets
I think i found THE solution. It's complicated so here is the teaser how to use it:
Button(action: {
showModal.wrappedValue = ShowModal {
AnyView( TheViewYouWantToPresent() )
}
})
Now you can define at the button level what you want to present. And the presenting view does not need to know anything. So you call this on the presenting view.
.background(EmptyView().show($showModal))
We call it on the background so the main view does not need to get updated, when $showModal changes.
Ok so what do we need to get this to work?
1: The ShowModal class:
public enum ModalType{
case sheet, fullscreen
}
public struct ShowModal: Identifiable {
public let id = ""
public let modalType: ModalType
public let content: () -> AnyView
public init (modalType: ModalType = .sheet, #ViewBuilder content: #escaping () -> AnyView){
self.modalType = modalType
self.content = content
}
}
Ignore id we just need it for Identifiable. With modalType we can present the view as sheet or fullscreen. And content is the passed view, that will be shown in the modal.
2: A ShowModal binding which stores the information for presenting views:
#State var showModal: ShowModal? = nil
And we need to add it to the environment of the view thats responsible for presentation. So we have easy access to it down the viewstack:
VStack{
InnerViewsThatWantToPresentModalViews()
}
.environment(\.showModal, $showModal)
.background(EmptyView().show($showModal))
In the last line we call .show(). Which is responsible for presentation.
Keep in mind that you have to create #State var showModal and add it to the environment again in a view thats shown modal and wants to present another modal.
4: To use .show we need to extend view:
public extension View {
func show(_ modal: Binding<ShowModal?>) -> some View {
modifier(VM_Show(modal))
}
}
And add a viewModifier that handles the information passed in $showModal
public struct VM_Show: ViewModifier {
var modal: Binding<ShowModal?>
public init(_ modal: Binding<ShowModal?>) {
self.modal = modal
}
public func body(content: Content) -> some View {
guard let modalType = modal.wrappedValue?.modalType else{ return AnyView(content) }
switch modalType {
case .sheet:
return AnyView(
content.sheet(item: modal){ modal in
modal.content()
}
)
case .fullscreen:
return AnyView(
content.fullScreenCover(item: modal) { modal in
modal.content()
}
)
}
}
}
4: Last we need to set showModal in views that want to present a modal:
Get the variable with: #Environment(\.showModal) var showModal. And set it like this:
Button(action: {
showModal.wrappedValue = ShowModal(modalType: .fullscreen) {
AnyView( TheViewYouWantToPresent() )
}
})
In the view that defined $showModal you set it without wrappedValue: $showModal = ShowModal{...}
As an alternative, simply putting a clear pixel somewhere in your layout might work for you:
Color.clear.frame(width: 1, height: 1, alignment: .center).sheet(isPresented: $showMySheet, content: {
MySheetView();
})
Add as many pixels as necessary.