I have an example struct:
public struct Axis: Hashable, CustomStringConvertible {
public var name: String
public var description: String {
return "Axis: \"\(name)\""
}
}
And property wrapper to make some operations on [Axis] struct.
#propertyWrapper
struct WrappedAxes {
var wrappedValue: [Axis] {
// This is just example, in real world it's much more complicated.
didSet {
for index in wrappedValue.indices {
var elems = Array(wrappedValue[index].name.split(separator: " "))
if elems.count>1 {
elems.removeLast()
}
let new = elems.reduce(into:"", {$0 += "\($1) "})
wrappedValue[index].name = new+("\(Date())")
} } } }
And I try to add, insert and remove Axes in SwiftUI View:
public struct ContentView: View {
#Binding var axes: [Axis]
public var body: some View {
VStack {
ForEach(axes.indices, id:\.self) {index in
HStack {
TextField("", text: $axes[index].name)
Button("Delete", action: {deleteAxis(index)})
Button("Insert", action: {insertAxis(index)})
}
}
Button("Add", action: addAxis)
}
}
var addAxis: () -> Void {
return {
axes.append(Axis(name: "New"))
print (axes)
}
}
var deleteAxis: (_:Int)->Void {
return {
if $0 < axes.count {
axes.remove(at: $0)
}
print (axes)
}
}
var insertAxis: (_:Int)->Void {
return {
if $0 < axes.count {
axes.insert(Axis(name: "Inserted"), at: $0)
}
print (axes)
}
}
public init (axes: Binding<[Axis]>) {
self._axes = axes
}
}
As far, as print (axes) shows changes are made, View never updates. I made very small App to test in which I call ContentView:
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
#WrappedAxes var axes = [Axis(name: "FirstOne")]
func applicationDidFinishLaunching(_ aNotification: Notification) {
let contentView = ContentView(
axes: Binding (
get: {self.axes},
set: { [self] in axes = $0}))
.... // No fancy stuff
I'm open for all critique of code itself, and help: how to push this view (and all possible future subviews) to update when axes changed?
The thing is that #Binding is for nested Views. When you want to make changes in a SwiftUI view, you have to use the #State instead in the one where the action starts. Plus, you don't need to set an initialiser this way. You can set your value like this, inside an ObservableObject to handle your logic:
struct Axis {
var name: String
var description: String {
return "Axis: \"\(name)\""
}
}
final class AxisViewModel: ObservableObject {
#Published var axes: [Axis] = [Axis(name: "First")]
init() { handleAxes() }
func addAxis() {
axes.append(Axis(name: "New"))
handleAxes()
}
func insertAxis(at index: Int) {
axes.insert(Axis(name: "Inserted"), at: index)
handleAxes()
}
func handleAxes() {
for index in axes.indices {
var elems = Array(axes[index].name.split(separator: " "))
if elems.count > 1 {
elems.removeLast()
}
let new = elems.reduce(into:"", { $0 += "\($1) " })
axes[index].name = new + ("\(Date())")
}
}
}
struct ContentView: View {
#ObservedObject var viewModel = AxisViewModel()
var body: some View {
VStack {
ForEach(viewModel.axes.indices, id:\.self) { index in
HStack {
TextField("", text: $viewModel.axes[index].name)
Button("Insert", action: { viewModel.insertAxis(at: index) })
}
}
Button("Add", action: viewModel.addAxis)
}
}
}
I made a new struct Axes:
public struct Axes {
#WrappedAxes var _axes: [Axis]
public subscript (index: Int) -> Axis {
get { return _axes[index]}
set { _axes[index] = newValue}
}
// and all needed functions and vars to simulate Array behaviour:
public mutating func append(_ newElement: Axis ) {
_axes.append(newElement)
}
public mutating func insert(_ newElement: Axis, at index: Int ) {
_axes.insert(newElement, at: index)
}
public mutating func remove(at index: Int ) {
_axes.remove(at: index)
}
....
}
and put it into #ObservalbeObject:
public class Globals: ObservableObject {
#Published public var axes: Axes
....
}
Then defined AxesView as
public struct AxesView: View {
#ObservedObject var globals: Globals
public var body: some View {
...
}
...
}
That's all. For a while it works.
Related
I'm trying to build a simple SwiftUI view that displays a number of toggles. While I can get everything to display ok, I cannot get the toggles to flip. Here's is a simplified code example:
import SwiftUI
class StoreableParam: Identifiable {
let name: String
var id: String { name }
#State var isEnabled: Bool
let toggleAction: ((Bool) -> Void)?
init(name: String, isEnabled: Bool, toggleAction: ((Bool) -> Void)? = nil) {
self.name = name
self.isEnabled = isEnabled
self.toggleAction = toggleAction
}
}
class StoreableParamViewModel: ObservableObject {
#Published var storeParams: [StoreableParam] = []
init() {
let storedParam = StoreableParam(name: "Stored Param", isEnabled: false) { value in
print("Value changed")
}
storeParams.append(storedParam)
}
}
public struct UBIStoreDebugView: View {
#StateObject var viewModel: StoreableParamViewModel
public var body: some View {
VStack {
List {
ForEach(viewModel.storeParams, id: \.id) { storeParam in
Toggle(storeParam.name, isOn: storeParam.$isEnabled)
.onChange(of: storeParam.isEnabled) {
storeParam.toggleAction?($0)
}
}
}
}.navigationBarTitle("Toggle Example")
}
}
As mentioned in the comments, there are a couple of things going on:
#State is only for use in a View
Your model should be a struct
Then, you can get a Binding using the ForEach element binding syntax:
struct StoreableParam: Identifiable {
let name: String
var id: String { name }
var isEnabled: Bool
let toggleAction: ((Bool) -> Void)?
}
class StoreableParamViewModel: ObservableObject {
#Published var storeParams: [StoreableParam] = []
init() {
let storedParam = StoreableParam(name: "Stored Param", isEnabled: false) { value in
print("Value changed")
}
storeParams.append(storedParam)
}
}
public struct UBIStoreDebugView: View {
#StateObject var viewModel: StoreableParamViewModel
public var body: some View {
VStack {
List {
ForEach($viewModel.storeParams, id: \.id) { $storeParam in
Toggle(storeParam.name, isOn: $storeParam.isEnabled)
.onChange(of: storeParam.isEnabled) {
storeParam.toggleAction?($0)
}
}
}
}
}
}
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
}
}
import Foundation
class Card: Identifiable, ObservableObject {
var id = UUID()
var result: String = ""
init() {
for _ in 1...10 {
let suit = ["D","K","B","S"][Int(arc4random() % 4]
let numStr = ["1","2","3","4"][Int(arc4random() % 4]
result = suit + numStr
}
}
}
import SwiftUI
struct EasyModeView: View {
#ObservedObject var cardModel: Card
var body : some View {
HStack {
ForEach((1...10, id: \.self) { _ in
Button { } label: { Image(cardModel.result) } } }
}
}
I created loop but it always shows me 10 same cards and i want all 10 different.
My Images in Assets are named same as combination of two variables example "D1","D2","S1","S2" etc.
Here is a right way of what you are trying:
struct ContentView: View {
var body: some View {
EasyModeView()
}
}
struct EasyModeView: View {
#StateObject var cardModel: Card = Card()
var body : some View {
HStack {
ForEach(cardModel.results) { card in
Button(action: {}, label: {
Text(card.result)
.padding(3.0)
.background(Color.yellow.cornerRadius(3.0))
})
}
}
Button("re-set") { cardModel.reset() }.padding()
}
}
struct CardType: Identifiable {
let id: UUID = UUID()
var result: String
}
class Card: ObservableObject {
#Published var results: [CardType] = [CardType]()
private let suit: [String] = ["D","K","B","S"]
init() { initializing() }
private func initializing() {
if (results.count > 0) { results.removeAll() }
for _ in 0...9 { results.append(CardType(result: suit.randomElement()! + String(describing: Int.random(in: 1...4)))) }
}
func reset() { initializing() }
}
Result:
You are using only one card, instead of creating 10. Fix like this
struct EasyModeView: View {
var body : some View {
HStack {
ForEach(0..<10) { _ in
Button { } label: {
Image(Card().result)
}
}
}
}
}
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)"))
}
}
}
I have a view:
struct Form: View {
#ObservedObject var model = FormInput()
var body: some View {
Form {
TextArea("Details", text: $model.details.value)
.validation(message: model.details.message)
}
}
}
Where TextArea is a custom view and .validation(message: model.details.message) is a custom view modifier. My FormInput looks as follows:
final class FormInput: ObservableObject {
#Published var details: TextInput
// ... other code
}
TextInput is a custom struct.
Now when I run the above code the validation never triggers because the Form never re-renders, but if I change the Form to:
struct MyForm: View {
#State var details = TextInput()
var body: some View {
Form {
TextArea("Details", text: $details.value)
.validation(message: details.message)
}
}
}
Then everything works as expected. Why would the view render for the second version of Form but not for the first? Shouldn't the Form in the first case update when details changes since details is #Published and the Form is observing the changes?
ADDITIONAL CONTEXT
Below is additional code for the above components
final class FormInput: ObservableObject {
#Published var details: TextInput
init(details: String = "") {
self.details = TextInput(value: details, isValid: false, validations: [.length(12)])
}
// other code
}
struct TextInput {
var value: String {
didSet {
self.validate()
}
}
var validations: [TextValidation] // this is just an enum of different types of validations
var isValid: Bool
var message: String
mutating func validate() {
for validation in validations {
// run validation
}
}
}
struct TextArea: View {
#Binding var text: String
#State var height: CGFloat = 12
init(text: Binding<String>) {
self._text = text
}
var body: some View {
TextAreaField(text: $text)
}
}
struct TextAreaField: UIViewRepresentable {
#Binding var text: String
#Binding var height: CGFloat
func makeUIView(context: Context) -> UITextView {
let textField = UITextView()
textField.isEditable = true
textField.isSelectable = true
textField.isUserInteractionEnabled = true
textField.isScrollEnabled = false
// ..other initializers removed for brevity
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: UITextView, context: Context) {
calculateHeight(uiView)
}
func calculateHeight(_ uiView: UIView) {
let newSize = uiView.sizeThatFits(CGSize(width: uiView.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
if self.height != newSize.height {
DispatchQueue.main.async {
self.height = newSize.height
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: TextAreaField
init(_ parent: TextAreaField) {
self.parent = parent
}
func textViewDidChange(_ uiView: UITextView) {
self.parent.text = uiView.text
self.parent.calculateHeight(uiView)
}
}
}
struct Validation: ViewModifier {
let message: String
func body(content: Content) -> some View {
let isValid = message == ""
print(message)
return VStack(alignment: .leading) {
content
if isValid == false {
Text(message)
.font(.footnote)
.italic()
.foregroundColor(Color.red)
.frame(height: 24)
}
}
.padding(.bottom, isValid ? 24 : 0)
}
}
extension View {
func validation(message: String) -> some View {
self.modifier(Validation(message: message))
}
}
I suppose the issue is in absent custom components. Because below simple demo replication of provided infrastructure works well with ObservableObject, actually as expected.
Tested with Xcode 11.4 / iOS 13.4. Comparing with below demo might be helpful to find what is missed in your code.
struct TextInput {
var value: String = "" {
didSet {
message = value // just duplication for demo
}
}
var message: String = ""
}
// simple validator highlighting text when too long
struct ValidationModifier: ViewModifier {
var text: String
func body(content: Content) -> some View {
content.foregroundColor(text.count > 5 ? Color.red : Color.primary)
}
}
extension View { // replicated
func validation(message: String) -> some View {
self.modifier(ValidationModifier(text: message))
}
}
struct MyForm: View { // avoid same names with standard views
#ObservedObject var model = FormInput()
var body: some View {
Form {
TextField("Details", text: $model.details.value) // used standard
.validation(message: model.details.message)
}
}
}
final class FormInput: ObservableObject {
#Published var details: TextInput = TextInput()
}
struct TestObservedInModifier: View {
var body: some View {
MyForm()
}
}