SwiftUI List selection always nil
A couple of suggestions:
- SwiftUI (specifically on macOS) is unreliable/unpredictable with certain
List
behaviors. One of them isselection
-- there are a number of things that either completely don't work or at best are slightly broken that work fine with the equivalent iOS code. The good news is thatNavigationLink
andisActive
works like a selection in a list -- I'll use that in my example. @Published
didSet
may work in certain situations, but that's another thing that you shouldn't rely on. The property wrapper aspect makes it behave differently than one might except (search SO for "@Published didSet" to see a reasonable number of issues dealing with it). The good news is that you can use Combine to recreate the behavior and do it in a safer/more-reliable way.
A logic error in the code:
- You are storing a
Week
in your user defaults with a certain UUID. However, you regenerate the array ofweeks
dynamically on every launch, guaranteeing that their UUIDs will be different. You need to store your week's along with your selection if you want to maintain them from launch to launch.
Here's a working example which I'll point out a few things about below:
import SwiftUI
import Combine
struct ContentView : View {
var body: some View {
NavigationView {
MenuView().environmentObject(UserSettings())
}
}
}
class UserSettings: ObservableObject {
@Published var weeks: [Week] = []
@Published var selectedWeek: UUID? = nil
private var cancellable : AnyCancellable?
private var initialItems = [
Week(name: "test week 1"),
Week(name: "foobar"),
Week(name: "hello world")
]
init() {
let decoder = PropertyListDecoder()
if let data = UserDefaults.standard.data(forKey: "weeks") {
weeks = (try? decoder.decode([Week].self, from: data)) ?? initialItems
} else {
weeks = initialItems
}
if let prevValue = UserDefaults.standard.string(forKey: "week.selected.id") {
selectedWeek = UUID(uuidString: prevValue)
print("Set selection to: \(prevValue)")
}
cancellable = $selectedWeek.sink {
if let id = $0?.uuidString {
UserDefaults.standard.set(id, forKey: "week.selected.id")
let encoder = PropertyListEncoder()
if let encoded = try? encoder.encode(self.weeks) {
UserDefaults.standard.set(encoded, forKey: "weeks")
}
}
}
}
func selectionBindingForId(id: UUID) -> Binding<Bool> {
Binding<Bool> { () -> Bool in
self.selectedWeek == id
} set: { (newValue) in
if newValue {
self.selectedWeek = id
}
}
}
}
//Unknown what you have in here
struct Day : Equatable, Hashable, Codable {
}
struct Week: Identifiable, Hashable, Equatable, Codable {
var id = UUID()
var days: [Day] = []
var name: String
}
struct WeekView : View {
var week : Week
var body: some View {
Text("Week: \(week.name)")
}
}
struct MenuView: View {
@EnvironmentObject var settings: UserSettings
var body: some View {
List {
ForEach(settings.weeks) { week in
NavigationLink(
destination: WeekView(week: week)
.environmentObject(settings),
isActive: settings.selectionBindingForId(id: week.id)
)
{
Image(systemName: "circle")
Text("\(week.name)")
}
}
.onDelete { set in
settings.weeks.remove(atOffsets: set)
}
.onMove { set, i in
settings.weeks.move(fromOffsets: set, toOffset: i)
}
}
.navigationTitle("Weekplans")
.listStyle(SidebarListStyle())
}
}
- In
UserSettings.init
the weeks are loaded if they've been saved before (guaranteeing the same IDs) - Use Combine on
$selectedWeek
instead ofdidSet
. I only store the ID, since it seems a little pointless to store the wholeWeek
struct, but you could alter that - I create a dynamic binding for the
NavigationLink
sisActive
property -- the link is active if the storedselectedWeek
is the same as theNavigationLink
's week ID. - Beyond those things, it's mostly the same as your code. I don't use
selection
onList
, justisActive
on theNavigationLink
- I didn't implement storing the
Week
again if you did theonMove
oronDelete
, so you would have to implement that.
How do I efficiently filter a long list in SwiftUI?
Have you tried passing a filtered array to the ForEach. Something like this:
ForEach(userData.bookList.filter { return !$0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) { BookRow(book: book) }
}
Update
As it turns out, it is indeed an ugly, ugly bug:
Instead of filtering the array, I just remove the ForEach all together when the switch is flipped, and replace it by a simple Text("Nothing")
view. The result is the same, it takes 30 secs to do so!
struct SwiftUIView: View {
@EnvironmentObject var userData: UserData
@State private var show = false
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if self.userData.showWantsOnly {
Text("Nothing")
} else {
ForEach(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
}
Workaround
I did find a workaround that works fast, but it requires some code refactoring. The "magic" happens by encapsulation. The workaround forces SwiftUI to discard the List completely, instead of removing one row at a time. It does so by using two separate lists in two separate encapsualted views: Filtered
and NotFiltered
. Below is a full demo with 3000 rows.
import SwiftUI
class UserData: ObservableObject {
@Published var showWantsOnly = false
@Published var bookList: [Book] = []
init() {
for _ in 0..<3001 {
bookList.append(Book())
}
}
}
struct SwiftUIView: View {
@EnvironmentObject var userData: UserData
@State private var show = false
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if userData.showWantsOnly {
Filtered()
} else {
NotFiltered()
}
}
}.navigationBarTitle(Text("Books"))
}
}
struct Filtered: View {
@EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList.filter { $0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct NotFiltered: View {
@EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct Book: Identifiable {
let id = UUID()
let own = Bool.random()
}
struct BookRow: View {
let book: Book
var body: some View {
Text("\(String(book.own)) \(book.id)")
}
}
struct BookDetail: View {
let book: Book
var body: some View {
Text("Detail for \(book.id)")
}
}
Related Topics
How Does Swift Disambiguate Type Arguments in Expression Contexts
Covert Realm List to Realm Result
F# Asynchronous Http Request - Parse JSON Response
Swift Set UIbutton Setbordercolor in Storyboard
Implementing Undo and Redo in a UItextview with Attributedtext
Wkwebview Calayer to Image Exports Blank Image
How to Enable a Button in Different Cases in Swift
Occasional Blank Frames After Exporting Asset - Avexportsession
Uibutton Background Color Overlaps Text on Highlight
How to Refer to a Global Type from Within a Class That Has a Nested Type with The Same Name
Arkit/Scenekit on iOS 14 Throws New Warning (Metal)
Why Swift Disallows Weak Reference for Non-Optional Type
How to Disable a Button of a Nstoolbar of Macos X in Swift
How to Replace My .Xib File with Pure Swift 3
Generating Random Doable Math Problems Swift
Compiling for iOS 10.3, But Module 'swiftuicharts' Has a Minimum Deployment Target of iOS 13.0