I have a class that implements the Equatable protocol, and it uses a UUID field for comparison:
class MemberViewModel {
private static var entities: [MemberViewModel] = []
private var entity: Member
let id = UUID()
init(_ entity: Member) {
self.entity = entity
}
static func members() -> [MemberViewModel] {
entities.removeAll()
try? fetch().forEach { member in
entities.append(MemberViewModel(member))
}
return entities
}
}
extension MemberViewModel: Equatable {
static func == (lhs: MemberViewModel, rhs: MemberViewModel) -> Bool {
return lhs.id == rhs.id
}
}
I then have a view that creates icons that when tapped should display a stroke to denote it was "selected":
struct MyView: View {
#State var selectedMember: MemberViewModel? = nil
var body: some View {
let members = MemberViewModel.members()
ScrollView(.horizontal, showsIndicators: true) {
HStack(alignment: .top, spacing: 4) {
ForEach (members, id: \.id) { member in
var isSelected: Bool = selectedMember == member
Circle()
.fill(Color(.red))
.frame(width: 48, height: 48)
.overlay() {
if isSelected {
Circle()
.stroke(Color(.black), lineWidth: 2)
}
}
.onTapGesture { selectedMember = member }
}
}
}
}
}
I have tried setting isSelected multiple ways, including the following from another SO question:
let isSelected = Binding<Bool>(get: { self.selectedMember == member }, set: { _ in })
When debugging using breakpoints, the value of isSelected is always false.
I'm using XCode Version 14.0.1 (14A400), and Swift 5.7.
Put let members = MemberViewModel.members() just before
var body: some View {...}, not inside it, like this:
let members = MemberViewModel.members() // <-- here
var body: some View {
...
}
Due to your weird code structure, let members = MemberViewModel.members()
gets re-done evey time the body is refreshed. And since the UUID is re-generated .... you can guess that the ids now are all over the place and not equal to the selectedMember.
In other words, the object comparison is working, but you are not comparing what you think.
Use the sample below hope you get the idea.
Model
struct Member {
let id = UUID()
}
extension Member: Equatable {
static func == (lhs: Member, rhs: Member) -> Bool {
return lhs.id == rhs.id
}
}
class MemberViewModel: ObservableObject {
// each time you update the entities, view vill be updated
#Published var entities: [Member] = []
func fetchMembers() {
entities.removeAll()
// your code to fetch members
try? fetch().forEach { member in
entities.append(member)
}
}
}
MyView
struct MyView: View {
#StateObject private var modelData = MemberViewModel()
#State private var selectedMember: Member? = nil
var body: some View {
ScrollView(.horizontal, showsIndicators: true) {
HStack(alignment: .top, spacing: 4) {
ForEach (modelData.entities, id: \.id) { member in
Circle()
.fill(Color(.red))
.frame(width: 48, height: 48)
.overlay() {
if selectedMember == member {
Circle()
.stroke(Color(.black), lineWidth: 2)
}
}
.onTapGesture { selectedMember = member }
}
}
}.onAppear {
modelData.fetchMembers()
}
}
}
Related
I'm building a custom component and using ForEach with a custom generic type. The issue is that this type is throwing this error:
Cannot convert value of type '[ASValue]' to expected argument type 'Binding'
public struct ArrayStepperSection<T: Hashable>: Hashable {
public let header: String
public var items: [ASValue<T>]
public init(header: String = "", items: [ASValue<T>]) {
self.header = header
self.items = items
}
}
public struct ASValue<T: Hashable>: Hashable {
private let id = UUID()
var item: T
public init(item: T) {
self.item = item
}
}
public class ArrayStepperValues<T: Hashable>: Hashable, ObservableObject {
#Published public var values: [ASValue<T>]
#Published public var selected: ASValue<T>
#Published public var sections: [ArrayStepperSection<T>]
public init(values: [ASValue<T>], selected: ASValue<T>, sections: [ArrayStepperSection<T>]? = nil) {
self.values = values
self.selected = selected
if sections != nil {
self.sections = sections!
} else {
self.sections = [ArrayStepperSection(items: values)]
}
}
public static func == (lhs: ArrayStepperValues<T>, rhs: ArrayStepperValues<T>) -> Bool {
return lhs.sections == rhs.sections
}
public func hash(into hasher: inout Hasher) {
hasher.combine(sections)
}
}
struct ArrayStepperList<T: Hashable>: View {
#Environment(\.dismiss) var dismiss
#ObservedObject var values: ArrayStepperValues<T>
let display: (T) -> String
var body: some View {
List {
ForEach(values.sections, id: \.self) { section in
Section(section.header) {
ForEach(section.items, id: \.self) { item in // Error happens here
Button(action: {
values.selected.item = item
dismiss()
}) {
HStack {
Text(display(item))
Spacer()
if values.selected.item == item {
Image(systemName: "checkmark")
}
}
}
}
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationTitle(Text(display(values.selected.item)))
}
}
here is the test code I used to remove the error:
struct ContentView: View {
#StateObject var values = ArrayStepperValues(values: [ASValue(item: "aaa"),ASValue(item: "bbb")], selected: ASValue(item: "aaa"))
var body: some View {
ArrayStepperList(values: values) // for testing
}
}
struct ArrayStepperList<T: Hashable>: View {
#Environment(\.dismiss) var dismiss
#ObservedObject var values: ArrayStepperValues<T>
// let display: (T) -> String // for testing
var body: some View {
List {
ForEach(values.sections, id: \.self) { section in
Section(section.header) {
ForEach(section.items, id: \.self) { item in
Button(action: {
DispatchQueue.main.async {
values.selected.item = item.item // <-- here
// dismiss() // for testing
}
}) {
HStack {
Text("\(item.item as! String)") // for testing
Spacer()
if values.selected.item == item.item { // <-- here
Image(systemName: "checkmark")
}
}
}
}
}
}
}
.listStyle(InsetGroupedListStyle())
// .navigationTitle(Text(display(values.selected.item))) // for testing
}
}
I have a rather strange problem with animation on adding elements to list in SwiftUI. I have a simple model class:
struct ShoppingList: Identifiable, Equatable {
let id: UUID
var name: String
var icon: String
var items: [Item]
init(id: UUID = UUID(), name: String, icon: String, items: [Item] = []) {
self.id = id
self.name = name
self.icon = icon
self.items = items
}
static func == (lhs: ShoppingList, rhs: ShoppingList) -> Bool {
return lhs.id == rhs.id
}
}
extension ShoppingList {
struct Item: Identifiable, Equatable {
let id: UUID
var name: String
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
static func == (lhs: Item, rhs: Item) -> Bool {
return lhs.id == rhs.id
}
}
}
When I pass single ShoppingList object as binding to the view, adding animation basically doesn't happen at all:
Here is code of my view:
struct ShoppingListDetailView: View {
#Binding var shoppingList: ShoppingList
#State private var newItemName = ""
var body: some View {
List {
ForEach($shoppingList.items) { $item in
Text(item.name)
}
HStack {
TextField("Add item", text: $newItem)
Button(action: addNewItem) {
Image(systemName: "plus.circle.fill")
}
.disabled(newItemName.isEmpty)
}
}
.navigationTitle(shoppingList.name)
}
private func addNewItem() {
withAnimation {
let newItem = ShoppingList.Item(name: newItemName)
shoppingList.items.append(newItem)
}
}
}
And here is code of a parent view:
struct ShoppingListsView: View {
#Binding var shoppingLists: [ShoppingList]
var body: some View {
List {
ForEach($shoppingLists) { $list in
NavigationLink(destination: ShoppingListDetailView(shoppingList: $list)) {
ShoppingListItemView(shoppingList: $list)
}
}
}
.navigationTitle("Shopping List")
}
}
But once I pass whole list of ShoppingList objects and index for a particular one, everything works as expected:
Code with passing list to the view looks like that:
struct ShoppingListDetailView: View {
#Binding var shoppingList: [ShoppingList]
var index: Int
#State private var newItemName = ""
var body: some View {
List {
ForEach($shoppingList[index].items) { $item in
Text(item.name)
}
HStack {
TextField("Add item", text: $newItem)
Button(action: addNewItem) {
Image(systemName: "plus.circle.fill")
}
.disabled(newItemName.isEmpty)
}
}
.navigationTitle(shoppingList[index].name)
}
private func addNewItem() {
withAnimation {
let newItem = ShoppingList.Item(name: newItemName)
shoppingList[index].items.append(newItem)
}
}
}
And of course parent view:
struct ShoppingListsView: View {
#Binding var shoppingLists: [ShoppingList]
var body: some View {
List {
ForEach($shoppingLists) { $list in
NavigationLink(destination: ShoppingListDetailView(shoppingList: $shoppingLists, index: shoppingLists.firstIndex(of: list)!)) {
ShoppingListItemView(shoppingList: $list)
}
}
}
.navigationTitle("Shopping List")
}
}
I'm new to Swift (not to mention SwiftUI) and I have no idea what might be wrong here. Any ideas?
I have a study project also you can call it testing/ learning project! Which has a goal to recreate the data source and trying get sinked with latest updates to data source!
In this project I was successful to create the same data source without sending or showing data source to the Custom View! like this example project in down, so I want keep the duplicated created data source with original data source updated!
For example: I am deleting an element in original data source and I am trying read the id of the View that get disappeared! for deleting the same one in duplicated one! but it does not work well!
The Goal: As you can see in my project and my codes! I want create and keep the duplicated data source sinked and equal to original without sending the data source directly to custom View! or even sending any kind of notification! the goal is that original data source should put zero work to make itself known to duplicated data source and the all work is belong to custom View to figure out the deleted item.
PS: please do not ask about use case or something like that! I am experimenting this method to see if it would work or where it could be helpful!
struct ContentView: View {
#State private var array: [Int] = Array(0...3)
var body: some View {
VStack(alignment: .leading, spacing: 10.0) {
ForEach(array.indices, id:\.self) { index in
CustomView(id: index) {
HStack {
Text(String(describing: array[index]))
.frame(width: 50.0)
Image(systemName: "trash")
.foregroundColor(.red)
.onTapGesture { array.remove(at: index) }
}
}
}
}
.font(Font.body.bold())
}
}
struct CustomView<Content: View>: View {
let id: Int
let content: () -> Content
#StateObject private var customModel: CustomModel = CustomModel.shared
var body: some View {
content()
.preference(key: IntPreferenceKey.self, value: id)
.onPreferenceChange(IntPreferenceKey.self) { newValue in
if !customModel.array.contains(newValue) { customModel.array.append(newValue) }
}
.onDisappear(perform: {
print("id:", id, "is going get removed!")
customModel.array.remove(at: id)
})
}
}
class CustomModel: ObservableObject {
static let shared: CustomModel = CustomModel()
#Published var array: Array<Int> = Array<Int>() {
didSet {
print(array.sorted())
}
}
}
struct IntPreferenceKey: PreferenceKey {
static var defaultValue: Int { get { return Int() } }
static func reduce(value: inout Int, nextValue: () -> Int) { value = nextValue() }
}
Relying on array indexes for ForEach will be unreliable for this sort of work, since the ID you're using is self -- ie the index of the item. This will result in unreliable recalculations of the items in ForEach since they're not actually identifiable by their index. For example, if item 0 gets removed, then what was item 1 now becomes item 0, making using an index as the identifier relatively useless.
Instead, use actual unique IDs to describe your models and everything works as expected:
struct Item: Identifiable {
let id = UUID()
var label : Int
}
struct ContentView: View {
#State private var array: [Item] = [.init(label: 0),.init(label: 1),.init(label: 2),.init(label: 3)]
var body: some View {
VStack(alignment: .leading, spacing: 10.0) {
ForEach(array) { item in
CustomView(id: item.id) {
HStack {
Text("\(item.label) - \(item.id)")
.frame(width: 50.0)
Image(systemName: "trash")
.foregroundColor(.red)
.onTapGesture { array.removeAll(where: { $0.id == item.id }) }
}
}
}
}
.font(.body.bold())
}
}
struct CustomView<Content: View>: View {
let id: UUID
let content: () -> Content
#StateObject private var customModel: CustomModel = CustomModel.shared
var body: some View {
content()
.preference(key: UUIDPreferenceKey.self, value: id)
.onPreferenceChange(UUIDPreferenceKey.self) { newValue in
if !customModel.array.contains(where: { $0 == id }) {
customModel.array.append(id)
}
}
.onDisappear(perform: {
print("id:", id, "is going to get removed!")
customModel.array.removeAll(where: { $0 == id })
})
}
}
class CustomModel: ObservableObject {
static let shared: CustomModel = CustomModel()
#Published var array: Array<UUID> = Array<UUID>() {
didSet {
print(array)
}
}
}
struct UUIDPreferenceKey: PreferenceKey {
static var defaultValue: UUID { get { return UUID() } }
static func reduce(value: inout UUID, nextValue: () -> UUID) { value = nextValue() }
}
What is the best approach to have swiftUI still update based on nested observed objects?
The following example shows what I mean with nested observed objects. The balls array of the ball manager is a published property that contains an array of observable objects, each with a published property itself (the color string).
Unfortunately, when tapping one of the balls it dos not update the balls name, nor does it receive an update. So I might have messed up how combine was ment to work in that case?
import SwiftUI
class Ball: Identifiable, ObservableObject {
let id: UUID
#Published var color: String
init(ofColor color: String) {
self.id = UUID()
self.color = color
}
}
class BallManager: ObservableObject {
#Published var balls: [Ball]
init() {
self.balls = []
}
}
struct Arena: View {
#StateObject var bm = BallManager()
var body: some View {
VStack(spacing: 20) {
ForEach(bm.balls) { ball in
Text(ball.color)
.onTapGesture {
changeBall(ball)
}
}
}
.onAppear(perform: createBalls)
.onReceive(bm.$balls, perform: {
print("ball update: \($0)")
})
}
func createBalls() {
for i in 1..<4 {
bm.balls.append(Ball(ofColor: "c\(i)"))
}
}
func changeBall(_ ball: Ball) {
ball.color = "cx"
}
}
When a Ball in the balls array changes, you can call objectWillChange.send() to update the ObservableObject.
The follow should work for you:
class BallManager: ObservableObject {
#Published var balls: [Ball] {
didSet { setCancellables() }
}
let ballPublisher = PassthroughSubject<Ball, Never>()
private var cancellables = [AnyCancellable]()
init() {
self.balls = []
}
private func setCancellables() {
cancellables = balls.map { ball in
ball.objectWillChange.sink { [weak self] in
guard let self = self else { return }
self.objectWillChange.send()
self.ballPublisher.send(ball)
}
}
}
}
And get changes with:
.onReceive(bm.ballPublisher) { ball in
print("ball update:", ball.id, ball.color)
}
Note: If the initial value of balls was passed in and not always an empty array, you should also call setCancellables() in the init.
You just create a BallView and Observe it and make changes from there. You have to Observe each ObservableObject directly
struct Arena: View {
#StateObject var bm = BallManager()
var body: some View {
VStack(spacing: 20) {
ForEach(bm.balls) { ball in
BallView(ball: ball)
}
}
.onAppear(perform: createBalls)
.onReceive(bm.$balls, perform: {
print("ball update: \($0)")
})
}
func createBalls() {
for i in 1..<4 {
bm.balls.append(Ball(ofColor: "c\(i)"))
}
}
}
struct BallView: View {
#ObservedObject var ball: Ball
var body: some View {
Text(ball.color)
.onTapGesture {
changeBall(ball)
}
}
func changeBall(_ ball: Ball) {
ball.color = "cx"
}
}
You do not need nested ObserverObjects for this example:
Model should be a simple struct:
struct Ball: Identifiable {
let id: UUID
let color: String
init(id: UUID = UUID(),
color: String) {
self.id = id
self.color = color
}
}
ViewModel should handle all the logic, that's why I have moved all the functions that manipulate balls here and made the array of balls private set. Because calling changeBall replaces one struct in the array with another one objectWillChange is fired an the view gets updated and onReceive gets triggered.
class BallManager: ObservableObject {
#Published private (set) var balls = [Ball]()
func changeBall(_ ball: Ball) {
guard let index = balls.firstIndex(where: { $0.id == ball.id }) else { return }
balls[index] = Ball(id: ball.id, color: "cx")
}
func createBalls() {
for i in 1..<4 {
balls.append(Ball(color: "c\(i)"))
}
}
}
The View should just communicate user intentions to the ViewModel:
struct Arena: View {
#StateObject var ballManager = BallManager()
var body: some View {
VStack(spacing: 20) {
ForEach(ballManager.balls) { ball in
Text(ball.color)
.onTapGesture {
ballManager.changeBall(ball)
}
}
}
.onAppear(perform: ballManager.createBalls)
.onReceive(ballManager.$balls) {
print("ball update: \($0)")
}
}
}
Model
The Ball is a model and could be a struct or a class (struct is usually recommended, you can find more information here)
ViewModel
Usually you use ObservableObject as a ViewModel or component that manages the data. It is usually common to set a default value for the balls (models), so you can set an empty array. Then you can populate the models with a network request from a database or storage.
The BallManager can be renamed to BallViewModel
The BallViewModel has a function that changes the underlying model based on the index in the ForEach component. The id: \.self basically renders the ball (model) for the current index.
Proposed solution
The following changes will work for achieving what you want to do
struct Ball: Identifiable {
let id: UUID
var color: String
init(ofColor color: String) {
self.id = UUID()
self.color = color
}
}
class BallViewModel: ObservableObject {
#Published var balls: [Ball] = []
func changeBallColor(in index: Int, color: String) {
balls[index] = Ball(ofColor: color)
}
}
struct Arena: View {
#StateObject var bm = BallViewModel()
var body: some View {
VStack(spacing: 20) {
ForEach(bm.balls.indices, id: \.self) { index in
Button(bm.balls[index].color) {
bm.changeBallColor(in: index, color: "cx")
}
}
}
.onAppear(perform: createBalls)
.onReceive(bm.$balls, perform: {
print("ball update: \($0)")
})
}
func createBalls() {
for i in 1..<4 {
bm.balls.append(Ball(ofColor: "c\(i)"))
}
}
}
So I tried to learn SwiftUI from Stanford CS193p. This works great, however, I can't get my head around why this is not working. I have the same exact view as the instructor has:
struct ContentView: View {
#ObservedObject var viewModel: EmojiMemoryGame
var body: some View {
HStack {
ForEach(self.viewModel.cards) { card in
CardView(card: card).onTapGesture {
self.viewModel.chooseCard(card: card)
}
}
.aspectRatio(2/3, contentMode: .fit)
}
.foregroundColor(.orange)
.padding()
.font(viewModel.numberOfPairsOfCards >= 5 ? .callout : .largeTitle)
}
}
struct CardView: View {
var card: MemoryGame<String>.Card
var body: some View {
VStack {
ZStack {
if card.isFaceUp {
RoundedRectangle(cornerRadius: 10.0).fill(Color.white)
RoundedRectangle(cornerRadius: 10.0).stroke(lineWidth: 3)
Text(card.content)
} else {
RoundedRectangle(cornerRadius: 10.0).fill(Color.orange)
}
}
}
}
}
The issue is that this does not update the view, it's as if the published information from the model does not get passed down the hierarchy. I know it works since if I change the code to this:
struct ContentView: View {
#ObservedObject var viewModel: EmojiMemoryGame
var body: some View {
HStack {
ForEach(self.viewModel.cards) { card in
ZStack {
if card.isFaceUp {
RoundedRectangle(cornerRadius: 10.0).fill(Color.white)
RoundedRectangle(cornerRadius: 10.0).stroke(lineWidth: 3)
Text(card.content)
} else {
RoundedRectangle(cornerRadius: 10.0).fill(Color.orange)
}
}
.onTapGesture {
self.viewModel.chooseCard(card: card)
}
}
.aspectRatio(2/3, contentMode: .fit)
}
.foregroundColor(.orange)
.padding()
.font(viewModel.numberOfPairsOfCards >= 5 ? .callout : .largeTitle)
}
}
all works well.
All help is greatly appreciated!
class EmojiMemoryGame: ObservableObject {
#Published private var game: MemoryGame<String> = EmojiMemoryGame.createMemoryGame()
static private func createMemoryGame() -> MemoryGame<String> {
let emojis = ["🎃", "👻", "🕷", "😈", "🦇"]
return MemoryGame(numberOfPairsOfCards: Int.random(in: 2...5)) { emojis[$0] }
}
//MARK: - Access to the Model
var cards: Array<MemoryGame<String>.Card> {
game.cards
}
var numberOfPairsOfCards: Int {
game.cards.count / 2
}
//MARK: - Intents
func chooseCard(card: MemoryGame<String>.Card) {
game.choose(card)
}
}
struct MemoryGame<CardContent> {
var cards: Array<Card>
mutating func choose(_ card: Card) {
if let indexOfCard = cards.firstIndex(of: card) {
cards[indexOfCard].isFaceUp.toggle()
}
}
init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
cards = Array<Card>()
for pairIndex in 0..<numberOfPairsOfCards {
let content = cardContentFactory(pairIndex);
cards.append(Card(content: content, id: pairIndex * 2))
cards.append(Card(content: content, id: pairIndex * 2 + 1))
}
cards.shuffle()
}
struct Card: Identifiable, Equatable {
static func == (lhs: MemoryGame<CardContent>.Card, rhs: MemoryGame<CardContent>.Card) -> Bool {
lhs.id == rhs.id
}
var isFaceUp = true
var isMatched = false
var content: CardContent
var id: Int
}
}
The implementation of the equality operator in your Card struct only compares ids. The CardView is not updated because SwiftUI deduces the card hasn't changed.
Note that you may want to check for the other properties of card as well (CardContent would need to conform to Equatable).
struct Card: Identifiable, Equatable {
static func == (lhs: MemoryGame<CardContent>.Card, rhs: MemoryGame<CardContent>.Card) -> Bool {
return lhs.id == rhs.id && lhs.isFaceUp == rhs.isFaceUp
}
var isFaceUp = true
var isMatched = false
var content: CardContent
var id: Int
}