I am observing a peculiar behavior in my SwiftUI code and narrowed it down to the following minimal example.
Given this example storage holding an array of book model structs.
struct Book: Identifiable {
let id: UUID
var likes: Int
var unusedProperty: String = ""
}
extension Book: Equatable {
static func == (lhs: Book, rhs: Book) -> Bool {
return lhs.id == rhs.id
}
}
class MyStorage: ObservableObject {
#Published var books: [Book] = [
.init(id: .init(uuidString: "B2A44450-BC03-47E6-85BE-E89EA69AF5AD")!, likes: 0),
.init(id: .init(uuidString: "F5AB9D18-DF73-433E-BB48-1C757CB6F8A7")!, likes: 0)
]
func addLike(to book: Book) {
for i in books.indices where books[i].id == book.id {
books[i].likes += 1
}
}
}
And using it in this simple view hierarchy:
struct ReducedContentView: View {
#StateObject var storage: MyStorage = MyStorage()
var body: some View {
VStack(spacing: 8) {
ForEach(storage.books) { book in
HStack {
VStack(alignment: .leading) {
Text("Top-Level: \(book.likes)")
BookView(book: book)
}
Spacer()
Button("Add Like") {
storage.addLike(to: book)
}
}.padding(.horizontal)
}
}
}
}
struct BookView: View {
let book: Book
var body: some View {
Text("Nested: \(book.likes)")
.foregroundColor(.red)
}
}
Any changes to the likes property don't propagate to the BookView, only to the "top-level" Text.
Now, if I change one of the following, it works:
remove the unusedProperty (which is needed in production)
add && lhs.likes == rhs.likes to the Equatable conformance (which is not intended)
modify BookView to accept #Binding var book: Book instead of a let
The last option is something I could adopt in my production code - nevertheless, I would really like to understand what's happening here, so any hints would be greatly appreciated.
This is a result of your custom Equatable conformance:
extension Book: Equatable {
static func == (lhs: Book, rhs: Book) -> Bool {
return lhs.id == rhs.id
}
}
If you remove this, it'll work as expected.
In your current code, you're saying that if two Books have the same ID, they are equal. My suspicion is that you don't actually mean that they are truly equal -- you just mean that they are the same book.
Your second option ("add && lhs.likes == rhs.likes to the Equatable conformance (which is not intended)") essentially just uses the synthesized Equatable conformance that the system generates, since unusedProperty isn't used -- so, if you were to use the second option, you may as well just remove the custom == function altogether.
I think the decision to make here is whether you really want to tell the system an untruth (that two Books, no matter what their other properties, are equal if they share the same id) or if you should let the system do it's own work telling if the items are equal or not.
Related
For structs, Swift auto synthesizes the hashValue for us. So I am tempting to just use it to conform to the identifiable.
I understand that hashValue is many to one, but ID must be one to one. However, hash collision is very rare and I have faith in the math that it's most likely never going to happen to my app before I die.
I am wondering is there any other problems besides collision?
This is my code:
public protocol HashIdentifiable: Hashable & Identifiable {}
public extension HashIdentifiable {
var id: Int {
return hashValue
}
}
using hashValue for id is a bad idea !
for example you have 2 structs
struct HashID: HashIdentifiable {
var number: Int
}
struct NormalID: Identifiable {
var id = UUID()
var number: Int
}
when number is changed:
HashID's id will be changed as well makes SwiftUI thinks that this is completely new item and old one is gone
NormalID's id stays the same, so SwiftUI knows that item only modified its property
It's very important to let SwiftUI knows what's going on, because it will affect animations, performance, ... That's why using hashValue for id makes your code looks bad and you should stay away from it.
I am wondering is there any other problems besides collision?
Yes, many.
E.g., with this…
struct ContentView: View {
#State private var toggleData = (1...5).map(ToggleDatum.init)
var body: some View {
List($toggleData) { $datum in
Toggle(datum.display, isOn: $datum.value)
}
}
}
Collisions cause complete visual chaos.
struct ToggleDatum: Identifiable & Hashable {
var value: Bool = false
var id: Int { hashValue }
var display: String { "\(id)" }
init(id: Int) {
// Disregard for now.
}
}
No collisions, with unstable identifiers, breaks your contract of "identity" and kills animation. Performance will suffer too, but nobody will care about that when it's so ugly even before they notice any slowness or battery drain.
struct ToggleDatum: Identifiable & Hashable {
var value: Bool = false
var id: Int { hashValue }
let display: String
init(id: Int) {
self.display = "\(id)"
}
}
However, while it is not acceptable to use the hash value as an identifier, it is fine to do the opposite: use the identifier for hashing, as long as you know the IDs to be unique for the usage set.
/// An `Identifiable` instance that uses its `id` for hashability.
public protocol HashableViaID: Hashable, Identifiable { }
// MARK: - Hashable
public extension HashableViaID {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct ToggleDatum: HashableViaID {
var value: Bool = false
let id: Int
var display: String { "\(id)" }
init(id: Int) {
self.id = id
}
}
This protocol works perfectly for Equatable classes, as classes already have a default ID ready for use.
extension Identifiable where Self: AnyObject {
public var id: ObjectIdentifier {
return ObjectIdentifier(self)
}
}
Not that I at all recommend using a reference type for this example, but it would look like this:
public extension Equatable where Self: AnyObject {
static func == (class0: Self, class1: Self) -> Bool {
class0 === class1
}
}
class ToggleDatum: HashableViaID {
I'm trying to make a List of favorite newspapers. In edit mode the list displays all available newspapers from which the user can select his favorites. After selecting favorites the list displays only the favorites. Here is my code:
struct Newspaper: Hashable {
let name: String
}
struct ContentView: View {
#State var editMode: EditMode = .inactive
#State private var selection = Set<Newspaper>()
var favorites: [Newspaper] {
selection.sorted(by: ({ $0.name < $1.name }))
}
let newspapers = [
Newspaper(name: "New York Times"),
Newspaper(name: "Washington Post")
]
var body: some View {
NavigationView {
List(editMode == .inactive ? favorites : newspapers, id: \.name, selection: $selection) { aliasItem in
Text(aliasItem.name)
}
.toolbar {
EditButton()
}
.environment(\.editMode, self.$editMode)
}
}
}
The problem is that the list enters edit mode, but the selection widgets don't appear. If I replace Newspaper with just an array of String (and modify the rest of the code accordingly), then the selection widgets do appear and the list works as expected. Can anyone explain what the problem is?
I originally tried using an Identifiable Newspaper like this:
struct Newspaper: Codable, Identifiable, Equatable, Hashable {
var id: String { alias + publicationName }
let alias: String
let publicationName: String
}
Since this didn't work, I tested the simpler version above to try to pinpoint the problem.
Since I need to save the favorites, the Newspaper has to be Codable and thus can't use UUID as they are read from disk and the complete Newspapers array is fetched from a server. That's why I have the id as a computed property.
Yrb:s answer provided the solution to the problem: the type of the selection Set has to be the same type as the id you are using in your Identifiable struct and not the type that you are displaying in the List.
So in my case (with the Identifiable Newspaper version) the selection Set has to be of type Set<String> and not Set<Newspaper> since the id of Newspaper is a String.
Your issue stems from the fact that List's selection mode uses the id: property to track your selection. Since you are declaring your List as List(..., id: \.name, ...), your selection var needs to be of type String. If you change it to List(..., id: \.self, ...), it will work, but using self in a list like that brings it own problems. In keeping with best practice, and forgetting the selection for a moment, you should be using an Identifiable struct. List should then identify the elements by the id parameter on the struct. (I used a UUID)
Working up to the selection, that means you need to define it as #State private var selection = Set<UUID>(). That leaves dealing with your favorites computed variable. Instead of returning an array of your selection, you simply filter the newspapers array for those element contained in selection. In the end, that leaves you with this:
struct Newspaper: Identifiable, Comparable {
let id = UUID()
let name: String
static func < (lhs: Newspaper, rhs: Newspaper) -> Bool {
lhs.name < rhs.name
}
}
struct ContentView: View {
#State var editMode: EditMode = .inactive
#State private var selection = Set<UUID>()
var favorites: [Newspaper] {
newspapers.filter { selection.contains($0.id) }
}
let newspapers = [
Newspaper(name: "New York Times"),
Newspaper(name: "Washington Post")
]
var body: some View {
NavigationView {
VStack {
List(editMode == .inactive ? favorites.sorted() : newspapers, selection: $selection) { aliasItem in
Text(aliasItem.name)
}
.toolbar {
EditButton()
}
.environment(\.editMode, self.$editMode)
Text(selection.count.description)
}
}
}
}
EDIT:
In your comment, you said that Newspaper needs to be 'Codable', and implied that there is no id parameter in the server response. The below Newspaper is Codable, but will not expect an id in the server response, but will simply add its own constant id. It is a very bad idea to have a computed id. id should never change and it should be unique. UUID gives you that.
struct Newspaper: Identifiable, Comparable, Codable {
let id = UUID()
let name: String
enum CodingKeys:String,CodingKey {
case name
}
static func < (lhs: Newspaper, rhs: Newspaper) -> Bool {
lhs.name < rhs.name
}
}
You can use a struct for a List's selection, but you have to tell SwiftUI about it, so it doesn’t use the struct’s ID. The original code works with an appropriate tag added, specifically:
Text(aliasItem.name)
.tag(aliasItem)
Say I have some enum, Channel, filled with sample property data:
enum Channel: CaseIterable {
case abc
case efg
case hij
var name: String {
switch self {
case .abc: return "ABC"
case .efg: return "EFG"
case .hij: return "HIJ"
}
}
}
extension Channel: Identifiable {
var id: Self { self }
}
And some view implementation to fill said content with:
struct ChannelHome: View {
var body: some View {
VStack {
ForEach(Channel.allCases) { channel in
NavigationLink(destination: ChannelDetail(channel)) {
Text(channel.name)
}
}
}
}
} // big closure yikes
Why doesn't this work? Is this due to the nature of enums not having static properties and SwiftUI being declarative? Are there ways around this? Such as:
struct ChannelData {
static let channels: [Channel] = [.abc, .efg, .hij]
// or
static func getChannels() -> [Channel] {
var channelArray: [Channel]
for channel in Channel.allCases {
channelArray.append(channel)
}
return channelArray
}
}
Or, more preferably, are there better practices for presenting small data sets like these in a SwiftUI view? I like being able to implement a visual UI of my properties for when I'm debugging, as a whole, at runtime (closer to the production stage).
I apologies in advance for the question, I am just getting into it with SwiftUI.
Here are 2 simple ways for you, but not sure where can be helpful for you because with enum you cannot update ForEach elements, but it has a use case for showing data!
First way without any Identifiable id:
struct ContentView: View {
var body: some View {
ForEach(TestEnum.allCases, id: \.rawValue) { item in
Text(item.rawValue)
}
}
}
enum TestEnum: String, CaseIterable { case a, b, c }
with Identifiable id that conforms to Identifiable protocol, you can cut , id: \.id even:
struct ContentView: View {
var body: some View {
ForEach(TestEnum.allCases, id: \.id) { item in
Text(item.rawValue)
}
}
}
enum TestEnum: String, CaseIterable, Identifiable { case a, b, c
var id: String { return self.rawValue }
}
Either Xcode was getting hung up on cache errors or I am a fool (probably the latter). Regardless, the issue seems to have been resolved with the following Identifiable extension to your enum, as well as a good ol' derived data purge and clean:
extension Channel: Identifiable {
var id: Self { self }
}
I seem to have confused this issue with another long-time issue that I've constantly run into; mostly regarding enums and their distaste for storing static data (as is simply just the nature of their fast and lite structure).
I have a ListView that is a few levels deep in a NavigationView. The idea is when a user selects an item in the ListView, the item is stored and the UI pops back to the root NavigationView.
Specific Implementation (Working)
class SpecificListItem: ObservableObject, Codable, Hashable, Identifiable {
var id: String
var kind: String
var name: String
var mimeType: String
init(id: String, kind: String, name: String, mimeType: String) {
self.id = id
self.kind = kind
self.name = name
self.mimeType = mimeType
}
static func == (lhs: DriveListItem, rhs: DriveListItem) -> Bool {
return lhs.id == rhs.id &&
lhs.kind == rhs.kind &&
lhs.name == rhs.name &&
lhs.mimeType == rhs.mimeType
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
func processItem() {
// do backend stuff
}
}
...
struct SpecificListView: View {
#EnvironmentObject var selectedItem: SpecificListItem
// load excluded for brevity
#ObservedObject var item: MyResponse<SpecificListResponse>
var body: some View {
List(selection: $selectedItem, content: {
ForEach(item.data?.files ?? []) {
file in HStack {
Text(file.name)
}
.onTapGesture {
// pop to root view
}
}
})
}
}
This was good for a proof of concept. Now I need to add a layer of abstraction.
Attempt at Generic Implementation
The selected item in this view should be generic. I have the following protocol and class defined that the selected item should conform to:
protocol SelectableItem: ObservableObject, Identifiable, Hashable, Codable {
var id: String { get set }
func process()
}
class GenericListItem: SelectableItem {
var id: String = ""
var name: String = ""
init () { }
init(id: String) {
self.id = id
}
static func == (lhs: GenericListItem, rhs: GenericListItem) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
func process() {
// do backend stuff
}
}
class SpecificListItem: GenericListItem {
var type: String
init () { }
init(id: String) {
self.id = id
}
override func process() {
// do backend stuff
}
}
...
struct GenericListView: View {
#EnvironmentObject var selectedItem: GenericListItem
// load excluded for brevity
#ObservedObject var item: MyResponse<SpecificListItem>
var body: some View {
List(selection: $selectedItem, content: {
ForEach(item.data?.files ?? []) {
file in HStack {
Text(file.name)
}
.onTapGesture {
// pop to root view
}
}
})
}
}
The selected item can have it's own data elements, but I am at least expecting each item to have an id and a process() method that interacts with my backend.
However, when I try to bind the selectedItem in the ListView, I get the following error: Generic parameter 'SelectionValue' could not be inferred.
I am assuming this is because the types do not match. I have read there isn't really any dynamic or run-time type binding possible in Swift? Is this true?
I am a little more experienced in Java, and I know I could achieve this through generics and polymorphism.
SpecificListItem IS-A GenericListItem yet Swift doesn't seem to like the design. (And MyResponse has-a SpecificListItem).
How can I define a generic class that can be selected from many child views and bound at the root view, yet can be inherited to implement their own specific logic?
Edit
My abstraction layer explanation was unclear if not just wrong. So I tried to fix that.
Adding some more information surrounding the code structure and design. Maybe it will help give some more background and context on what I am trying to accomplish.
Here is a small representation of the object interactions:
The idea is I will have many detail views in the application. The backend will return various response types, all conforming to SelectableItem. In this case, the detail view item source is of type SpecificListItem.
And here is the interaction between the user, backend, and UI:
And the idea here being:
User navigates to Detail View
Backend call is made, returns a list of SpecificListItem
List View is populated with items of type SpecifcListItem.
When a user selects an item from Detail View, the application pops back to root, and the selectedItem is set in the root view which is of type GenericListItem.
I have a SwiftUI List like in the example code below.
struct ContentView: View {
#State var numbers = ["1", "2", "3"]
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(numbers, id: \.self) { number in
Text(number)
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
}
When I enter edit mode and move the item one position up the strange animation happens after I drop the item (see the gif below). It looks like the dragged item comes back to its original position and then moves to the destination again (with animation)
What's interesting it doesn't happen if you drag the item down the list or more than one position up.
I guess it's because the List performs animation when the items in the state get reordered even though they were already reordered on the view side by drag and drop. But apparently it handles it well in all the cases other than moving item one position up.
Any ideas on how to solve this issue? Or maybe it's a known bug?
I'm using XCode 11.4.1 and the build target is iOS 13.4
(Please also note that in the "real world" app I'm using Core Data and when moving items their order is updated in the DB and then the state is updated, but the problem with the animation looks exactly the same.)
Here is solution (tested with Xcode 11.4 / iOS 13.4)
var body: some View {
NavigationView {
List {
ForEach(numbers, id: \.self) { number in
HStack {
Text(number)
}.id(UUID()) // << here !!
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
Here's a solution based on Mateusz K's comment in the accepted answer. I combined the hashing of order and number. I'm using a complex object in place of number which gets dynamically updated. This way ensures the list item refreshes if the underlying object changes.
class HashNumber : Hashable{
var order : Int
var number : String
init(_ order: Int, _ number:String){
self.order = order
self.number = number
}
static func == (lhs: HashNumber, rhs: HashNumber) -> Bool {
return lhs.number == rhs.number && lhs.order == rhs.order
}
//
func hash(into hasher: inout Hasher) {
hasher.combine(number)
hasher.combine(order)
}
}
func createHashList(_ input : [String]) -> [HashNumber]{
var r : [HashNumber] = []
var order = 0
for i in input{
let h = HashNumber(order, i)
r.append(h)
order += 1
}
return r
}
struct ContentView: View {
#State var numbers = ["1", "2", "3"]
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(createHashList(numbers), id: \.self) { number in
Text(number.number)
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
}
I know that this wasn't what caused issues for the Author of this Question, but I identified what caused the animation glitch for my list.
I was setting the id of each View produced by the ForEach View within my List View, to one that would get assigned to another one of those Views after dragging and dropping a row to re-order it, as the id's were set based on a common substring, paired with the index with which the given ForEach-iteration's product corresponded.
Here's a simple code snippet to demonstrate the mistake I (but not the Author of this Question) made:
struct ContentView: View {
private let shoppingListName: String
#FetchRequest
private var products: FetchedResults<Product>
init(shoppingList: ShoppingList) {
self.shoppingListName = shoppingList.name?.capitalized ?? "Unknown"
self._products = FetchRequest(
sortDescriptors: [
.init(keyPath: \Product.orderNumber, ascending: true)
],
predicate: .init(format: "shoppingList == %#", shoppingList.objectID)
)
var body: some View {
NavigationView {
List {
ForEach(Array(products.enumerated()), id: \element.objectID) { index, product in
Text(product.name ?? "Unknown")
.id("product-\(index)") // Problematic
}
.onMove {
var products = Array(products)
products.move(fromOffsets: $0, toOffset: $1)
for (index, product) in products.enumerated() {
product.orderNumber = Int64(index)
}
}
}
.toolbar {
ToolbarItem {
EditButton()
}
}
.navigationBarTitle("\(shoppingListName) Shopping List")
}
}
}
If, when running the above code, I were to reorder item at index 2 (i.e. row with id "product-2") to index 0, then the row I'd reordered would start having the id which the row that was previously at index 0 had. And the row that was previously at index 0 would start having the id of the row directly below it, and so on and so forth.
This re-assignment of existing id's to other Views within the same list in response to a row being reordered within that list, would confuse SwiftUI, and cause there to be an animation glitch whilst the row being reordered moved into its correct new position after having been dropped.
P.S. I recommend that readers who investigate their code to see if they've made this same mistake, do the following:
Check to see if you're setting the id's of the "row" Views based on any value that can get "shifted around" amongst the rows within the list, in response to a row being reordered. Such a value could be an index, but it could also be something else, such as the value of an orderNumber property that you store in the NSManagedObject-Subclass instances over which you're looping in the ForEach View.
Also, check to see if you're calling any custom View methods on the "row" Views, and if so, investigate to see whether or not any of those custom View methods are setting id's for the View's on which they're being called. <-- This was the case in my real code, which made my mistake a bit harder for me to spot :P!
I have same problem. I do not know bug or not, but I found workaround solution.
class Number: ObservableObject, Identifiable {
var id = UUID()
#Published var number: String
init(_ number: String) {
self.number = number
}
}
class ObservedNumbers: ObservableObject {
#Published var numbers = [ Number("1"), Number("2"), Number("3") ]
func onMove(fromOffsets: IndexSet, toOffset: Int) {
var newNumbers = numbers.map { $0.number }
newNumbers.move(fromOffsets: fromOffsets, toOffset: toOffset)
for (newNumber, number) in zip(newNumbers, numbers) {
number.number = newNumber
}
self.objectWillChange.send()
}
}
struct ContentView: View {
#ObservedObject var observedNumbers = ObservedNumbers()
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(observedNumbers.numbers) { number in
Text(number.number)
}
.onMove(perform: observedNumbers.onMove)
}
.navigationBarItems(trailing: EditButton())
}
}
}
In CoreData case I just use NSFetchedResultsController. Implementation of ObservedNumbers.onMove() method looks like:
guard var hosts = frc.fetchedObjects else {
return
}
hosts.move(fromOffsets: set, toOffset: to)
for (order, host) in hosts.enumerated() {
host.orderPosition = Int32(order)
}
try? viewContext.save()
And in delegate:
internal func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any,
at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)
{
switch type {
case .delete:
hosts[indexPath!.row].stopTrack()
hosts.remove(at: indexPath!.row)
case .insert:
let hostViewModel = HostViewModel(host: frc.object(at: newIndexPath!))
hosts.insert(hostViewModel, at: newIndexPath!.row)
hostViewModel.startTrack()
case .update:
hosts[indexPath!.row].update(host: frc.object(at: indexPath!))
case .move:
hosts[indexPath!.row].update(host: frc.object(at: indexPath!))
hosts[newIndexPath!.row].update(host: frc.object(at: newIndexPath!))
default:
return
}
}
In my case (again, cannot explain why but it could maybe help someone)
I changed the ForEach(self.houses, id: \.id) { house in ...
into
ForEach(self.houses.indices, id: \.self) { i in
Text(self.houses[i].name)
.id(self.houses[i].id)
}