Dynamically sized #State var - swift

I'm loading data into a struct from JSON. With this data, a new structure (itemStructures) is created and filled for use in a SwiftUI View. In this View I have a #State var which I manually initialise to have enough space to hold all items. This state var holds all parameters which are nested within the items, hence the nested array.
As long as this #State var has enough empty spaces everything works fine. But my question is, how do I modify this #State programmatically for when the number of items increases er decreases with the loading of a new JSON? I could make it really large but I'd rather have it the exact size after each load.
//Structs used in this example
struct MainViewState {
var itemStructures: [ItemStructure]
}
struct ItemStructure: Identifiable, Hashable {
var id: String {name}
var name: String
var parameters: [Parameter]
}
struct Parameter: Identifiable, Hashable {
var id: String {name}
var name: String
var value: Double
var range: [Double]
}
struct ContentView: View {
//In this model json is loaded, this seemed out of scope for this question to include this
#ObservedObject var viewModel: MainViewModel
//This is the #State var which should be dynamically allocated according to the content size of "itemStructures"
//For now 3 items with 10 parameters each are enough
#State var parametersPerItem: [[Float]] = [
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0]
]
init(viewModel: MainViewModel) {
self.viewModel = viewModel
}
var body: some View {
let itemStructures = viewModel.mainState.itemStructures
ForEach( Array(itemStructures.enumerated()), id: \.element ) { index, item in
Text(item.name)
ForEach( Array(item.parameters.enumerated()), id: \.element ) { i, parameter in
Text(parameter.name)
SliderView(
label: parameter.name,
value: Binding(
get: { self.parametersPerItem[index][i] },
set: { (newVal) in
self.parametersPerItem[index][i] = newVal
//Function to send slider values and ranges to real time processing
//doStuffWithRangesAndValues()
}
),
range: parameter.range,
showsLabel: false
).onAppear {
//Set initial value slider
parametersPerItem[index][i] = Float(parameter.value)
}
}
}
}
}
struct SliderView: View {
var label: String
#Binding var value: Float
var range: [Double]
var showsLabel: Bool
init(label: String, value: Binding<Float>, range: [Double], showsLabel: Bool = true) {
self.label = label
_value = value
self.range = range
self.showsLabel = showsLabel
}
var body: some View {
GeometryReader { geometry in
ZStack{
if showsLabel { Text(label) }
HStack {
Slider(value: $value)
.foregroundColor(.accentColor)
.frame(width: geometry.size.width * 0.8)
//In the real app range calculations are done here
let valueInRange = value
Text("\(valueInRange, specifier: range[1] >= 1000 ? "%.0f" : "%.2f")")
.foregroundColor(.white)
.font(.subheadline)
.frame(width: geometry.size.width * 0.2)
}
}
}
.frame(height: 40.0)
}
}

If you are looking for a solution where you want to initialise the array after the json has been loaded you could add a computed property in an extension to the main/root json model and use it to give the #State property an initial value.
extension MainViewState {
var parametersPerItem: [[Float]] {
var array: [[Float]] = []
if let max = itemStructures.map(\.parameters.count).max(by: { $0 < $1 }) {
for _ in itemStructures {
array.append(Array(repeating: 0.0, count: max))
}
}
return array
}
}

Related

Can't get an array of objects to update the view

I have a fairly simple piece of code that I am trying to write containing a displayed array and a draggable object inside that array. The problem though is that it compiles and displays the array, but does not update the view when I tap and drag the object, despite the terminal showing that the tOffset variable is getting constantly updated.
I am 90% sure I am just not getting the property wrapper right, maybe #ObservedObject doesn't work for arrays? And feedback is appreciated
.
Here is my class declaration:
class Token: Identifiable, ObservableObject{
var id: UUID
var name: String
var scale: CGFloat
#Published var sOffset: CGSize
#Published var cOffset: CGSize
#Published var tOffset: CGSize
init(n: String) {
id = UUID()
name = n
scale = 2.0
sOffset = .zero
cOffset = .zero
tOffset = .zero
updateTOffset()
}
public func updateTOffset() {
tOffset.height = sOffset.height + cOffset.height
tOffset.width = sOffset.width + cOffset.width
print("Updated T Offset , x: \(tOffset.width), y:\(tOffset.height) ")
}
}
class varToken: Token {
var exp: expToken?
init(name: String, exp: expToken?) {
super.init(n: name)
self.exp = exp
}
}
class expToken: Token {
#Published var bOffset: CGSize = .zero
init(name: String) {
super.init(n: name)
sOffset.height = -10
scale = 1.0
updateTOffset()
}
}
class tokenArray: Identifiable, ObservableObject {
var id: UUID = UUID()
#Published var tokens: [varToken] = [varToken(name: "1 +", exp: nil), varToken(name: "x", exp: expToken(name: "2"))]
}
I created it intending for it to conform to Observable Object. The view is very simple, with all elements in one view struct:
struct ViewDemo2: View {
#ObservedObject var t: tokenArray = tokenArray()
var body: some View {
HStack(alignment: .bottom) {
ForEach(t.tokens) { token in
Text(token.name)
.scaleEffect(token.scale, anchor: .center)
.offset(token.tOffset)
.padding(.leading, 10)
if token.exp != nil {
Text(token.exp!.name)
.scaleEffect(token.exp!.scale)
.offset(token.exp!.tOffset)
.gesture(
DragGesture()
.onChanged() { value in
withAnimation(.spring()) {
token.exp!.cOffset = value.translation
token.exp!.updateTOffset()
}
}
.onEnded() { value in
withAnimation(.spring()) {
token.exp!.cOffset = .zero
token.exp!.updateTOffset()
}
}
)
}
}
}
}
}

SwiftUI - using variable count within ForEach

I have a view created through a ForEach loop which needs to take a variable count within the ForEach itself i.e. I need the app to react to a dynamic count and change the UI accoridngly.
Here is the view I am trying to modify:
struct AnimatedTabSelector: View {
let buttonDimensions: CGFloat
#ObservedObject var tabBarViewModel: TabBarViewModel
var body: some View {
HStack {
Spacer().frame(maxWidth: .infinity).frame(height: 20)
.background(Color.red)
ForEach(1..<tabBarViewModel.activeFormIndex + 1) { _ in
Spacer().frame(maxWidth: buttonDimensions).frame(height: 20)
.background(Color.blue)
Spacer().frame(maxWidth: .infinity).frame(height: 20)
.background(Color.green)
}
Circle().frame(
width: buttonDimensions,
height: buttonDimensions)
.foregroundColor(
tabBarViewModel.activeForm.loginFormViewModel.colorScheme
)
ForEach(1..<tabBarViewModel.loginForms.count - tabBarViewModel.activeFormIndex) { _ in
Spacer().frame(maxWidth: .infinity).frame(height: 20)
.background(Color.red)
Spacer().frame(maxWidth: buttonDimensions).frame(height: 20)
.background(Color.blue)
}
Spacer().frame(maxWidth: .infinity).frame(height: 20)
.background(Color.gray)
}
}
}
And the viewModel I am observing:
class TabBarViewModel: ObservableObject, TabBarCompatible {
var loginForms: [LoginForm]
#Published var activeForm: LoginForm
#Published var activeFormIndex = 0
private var cancellables = Set<AnyCancellable>()
init(loginForms: [LoginForm]) {
self.loginForms = loginForms
self.activeForm = loginForms[0] /// First form is always active to begin
setUpPublisher()
}
func setUpPublisher() {
for i in 0..<loginForms.count {
loginForms[i].loginFormViewModel.$isActive.sink { isActive in
if isActive {
self.activeForm = self.loginForms[i]
self.activeFormIndex = i
}
}
.store(in: &cancellables)
}
}
}
And finally the loginFormViewModel:
class LoginFormViewModel: ObservableObject {
#Published var isActive: Bool
let name: String
let icon: Image
let colorScheme: Color
init(isActive: Bool = false, name: String, icon: Image, colorScheme: Color) {
self.isActive = isActive
self.name = name
self.icon = icon
self.colorScheme = colorScheme
}
}
Basically, a button on the login form itself sets its viewModel's isActive property to true. We listen for this in TabBarViewModel and set the activeFormIndex accordingly. This index is then used in the ForEach loop. Essentially, depending on the index selected, I need to generate more or less spacers in the AnimatedTabSelector view.
However, whilst the activeIndex variable is being correctly updated, the ForEach does not seem to react.
Update:
The AnimatedTabSelector is declared as part of this overall view:
struct TabIconsView: View {
struct Constants {
static let buttonDimensions: CGFloat = 50
static let buttonIconSize: CGFloat = 25
static let activeButtonColot = Color.white
static let disabledButtonColor = Color.init(white: 0.8)
struct Animation {
static let stiffness: CGFloat = 330
static let damping: CGFloat = 22
static let velocity: CGFloat = 7
}
}
#ObservedObject var tabBarViewModel: TabBarViewModel
var body: some View {
ZStack {
AnimatedTabSelector(
buttonDimensions: Constants.buttonDimensions,
tabBarViewModel: tabBarViewModel)
HStack {
Spacer()
ForEach(tabBarViewModel.loginForms) { loginForm in
Button(action: {
loginForm.loginFormViewModel.isActive = true
}) {
loginForm.loginFormViewModel.icon
.font(.system(size: Constants.buttonIconSize))
.foregroundColor(
tabBarViewModel.activeForm.id == loginForm.id ? Constants.activeButtonColot : Constants.disabledButtonColor
)
}
.frame(width: Constants.buttonDimensions, height: Constants.buttonDimensions)
Spacer()
}
}
}
.animation(Animation.interpolatingSpring(
stiffness: Constants.Animation.stiffness,
damping: Constants.Animation.damping,
initialVelocity: Constants.Animation.velocity)
)
}
}
UPDATE:
I tried another way by adding another published to the AnimatedTabSelector itself to check that values are indeed being updated accordingly. So at the end of the HStack in this view I added:
.onAppear {
tabBarViewModel.$activeFormIndex.sink { index in
self.preCircleSpacers = index + 1
self.postCircleSpacers = tabBarViewModel.loginForms.count - index
}
.store(in: &cancellables)
}
And of course I added the following variables to this view:
#State var preCircleSpacers = 1
#State var postCircleSpacers = 6
#State var cancellables = Set<AnyCancellable>()
Then in the ForEach loops I changed to:
ForEach(1..<preCircleSpacers)
and
ForEach(1..<postCircleSpacers)
respectively.
I added a break point in the new publisher declaration and it is indeed being updated with the expected figures. But the view is still failing to reflect the change in values
OK so I seem to have found a solution - what I am presuming is that ForEach containing a range does not update dynamically in the same way that ForEach containing an array of objects does.
So rather than:
ForEach(0..<tabBarViewModel.loginForms.count)
etc.
I changed it to:
ForEach(tabBarViewModel.loginForms.prefix(tabBarViewModel.activeFormIndex))
This way I still iterate the required number of times but the ForEach updates dynamically with the correct number of items in the array

How to set up Binding of computed value inside class (SwiftUI)

In my Model I have an array of Items, and a computed property proxy which using set{} and get{} to set and return currently selected item inside array and works as shortcut. Setting item's value manually as model.proxy?.value = 10 works, but can't figure out how to Bind this value to a component using $.
import SwiftUI
struct Item {
var value: Double
}
class Model: ObservableObject {
#Published var items: [Item] = [Item(value: 1), Item(value: 2), Item(value: 3)]
var proxy: Item? {
get {
return items[1]
}
set {
items[1] = newValue!
}
}
}
struct ContentView: View {
#StateObject var model = Model()
var body: some View {
VStack {
Text("Value: \(model.proxy!.value)")
Button(action: {model.proxy?.value = 123}, label: {Text("123")}) // method 1: this works fine
SubView(value: $model.proxy.value) // method 2: binding won't work
}.padding()
}
}
struct SubView <B:BinaryFloatingPoint> : View {
#Binding var value: B
var body: some View {
Button( action: {value = 100}, label: {Text("1")})
}
}
Is there a way to modify proxy so it would be modifiable and bindable so both methods would be available?
Thanks!
Day 2: Binding
Thanks to George, I have managed to set up Binding, but the desired binding with SubView still won't work. Here is the code:
import SwiftUI
struct Item {
var value: Double
}
class Model: ObservableObject {
#Published var items: [Item] = [Item(value: 0), Item(value: 0), Item(value: 0)]
var proxy: Binding <Item?> {
Binding <Item?> (
get: { self.items[1] },
set: { self.items[1] = $0! }
)
}
}
struct ContentView: View {
#StateObject var model = Model()
#State var myval: Double = 10
var body: some View {
VStack {
Text("Value: \(model.proxy.wrappedValue!.value)")
Button(action: {model.proxy.wrappedValue?.value = 555}, label: {Text("555")})
SubView(value: model.proxy.value) // this still wont work
}.padding()
}
}
struct SubView <T:BinaryFloatingPoint> : View {
#Binding var value: T
var body: some View {
Button( action: {value = 100}, label: {Text("B 100")})
}
}
Create a Binding instead.
Example:
var proxy: Binding<Item?> {
Binding<Item?>(
get: { items[1] },
set: { items[1] = $0! }
)
}

Passing calculated variable to another View

I am trying to create my own grid, which resizing to every element. It's okay with that. Here is a code
GeometryReader { geo in
let columnCount = Int((geo.size.width / 250).rounded(.down))
let tr = CDNresponse.data?.first?.translations ?? []
let rowsCount = (CGFloat(tr.count) / CGFloat(columnCount)).rounded(.up)
LazyVStack {
ForEach(0..<Int(rowsCount), id: \.self) { row in // create number of rows
HStack {
ForEach(0..<columnCount, id: \.self) { column in // create columns
let index = row * columnCount + column
if index < (tr.count) {
VStack {
MovieCellView(index: index, tr: tr, qualities: $qualities)
}
}
}
}
}
}
}
But since I don't know the exact number of elements and their indices, I need to calculate them in view.
let index = row * columnCount + column
And that's the problem - if I pass them as usual (MovieCellView(index: index ... )) when the index changing, the new value is not passed to view.
I cannot use #State and #Binding, as I cannot declare it directly in View Builder and can't declare it on struct because I don't know the count. How to pass data correctly?
Code of MovieCellView:
struct MovieCellView: View {
#State var index: Int
#State var tr: [String]
#State var showError: Bool = false
#State var detailed: Bool = false
#Binding var qualities: [String : [Int : URL]]
var body: some View {
...
}
}
The most simple example
Just added Text("\(index)") in VStack with MovieCellView and Text("\(index)") in MovieCellView body. Index in VStack always changing, but not in MovieCellView.
It is necessary to clarify, my application is on a macOS and the window is resized
This is my solution (test code) to your issue of passing calculated variable to another View.
I use an ObservableObject to store the information needed to achieve what you are after.
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class MovieCellModel: ObservableObject {
#Published var columnCount: Int = 0
#Published var rowCount: Int = 0
// this could be in the view
func index(row: Int, column: Int) -> Int {
return row * columnCount + column
}
}
struct ContentView: View {
#StateObject var mcModel = MovieCellModel()
#State var qualities: [String : [Int : URL]] = ["":[1: URL(string: "https://example.com")!]] // for testing
let tr = CDNresponse.data?.first?.translations ?? [] // for testing
var body: some View {
VStack {
GeometryReader { geo in
LazyVStack {
ForEach(0..<Int(mcModel.rowCount), id: \.self) { row in // create number of rows
HStack {
ForEach(0..<mcModel.columnCount, id: \.self) { column in // create 3 columns
if mcModel.index(row: row, column: column) < (tr.count) {
VStack {
MovieCellView(index: mcModel.index(row: row, column: column), tr: tr, qualities: $qualities)
}
}
}
}
}
}.onAppear {
mcModel.columnCount = Int((geo.size.width / 250).rounded(.down))
mcModel.rowCount = Int((CGFloat(tr.count) / CGFloat(mcModel.columnCount)).rounded(.up))
}
}
}
}
}
struct MovieCellView: View {
#State var index: Int
#State var tr: [String]
#Binding var qualities: [String : [Int : URL]]
#State var showError: Bool = false
#State var detailed: Bool = false
var body: some View {
Text("\(index)").foregroundColor(.red)
}
}
The only variable that changes outside of a ForEach in your index calculation is columnCount. row and column are simply indices from the loops. So you can declare columnCount as a #State variable on the parent view, hence when it changes, the parent view will update and hence all of the cells will also update with the new columnCount.
#State private var columnCount: CGFloat = 0
var body: some View {
GeometryReader { geo in
columnCount = Int((geo.size.width / 250).rounded(.down))
let tr = CDNresponse.data?.first?.translations ?? []
let rowsCount = (CGFloat(tr.count) / CGFloat(columnCount)).rounded(.up)
LazyVStack {
ForEach(0..<Int(rowsCount), id: \.self) { row in // create number of rows
HStack {
ForEach(0..<columnCount, id: \.self) { column in // create 3 columns
let index = row * columnCount + column
if index < (tr.count) {
VStack {
MovieCellView(index: index, tr: tr, qualities: $qualities)
}
}
}
}
}
}
}
}
Since none of the answers worked, and only one worked half, I solved the problem myself. You need to generate a Binding, and then just pass it
var videos: some View {
GeometryReader { geo in
let columnCount = Int((geo.size.width / 250).rounded(.down))
let rowsCount = (CGFloat((CDNresponse.data?.first?.translations ?? []).count) / CGFloat(columnCount)).rounded(.up)
LazyVStack {
ForEach(0..<Int(rowsCount), id: \.self) { row in // create number of rows
HStack {
ForEach(0..<columnCount, id: \.self) { column in // create 3 columns
let index = row * columnCount + column
if index < ((CDNresponse.data?.first?.translations ?? []).count) {
MovieCellView(translation: trBinding(for: index), qualities: qualityBinding(for: (CDNresponse.data?.first?.translations ?? [])[index]))
}
}
}
}
}
}
}
private func qualityBinding(for key: String) -> Binding<[Int : URL]> {
return .init(
get: { self.qualities[key, default: [:]] },
set: { self.qualities[key] = $0 })
}
private func trBinding(for key: Int) -> Binding<String> {
return .init(
get: { (self.CDNresponse.data?.first?.translations ?? [])[key] },
set: { _ in return })
}
struct MovieCellView: View {
#State var showError: Bool = false
#State var detailed: Bool = false
#Binding var translation: String
#Binding var qualities: [Int : URL]
var body: some View {
...
}
}

Make view not redraw after updating ObservableObject

I have a situation where I am using LazyVGrid to display Images from a users Photo library. The grid has sections, and each section contains an array of images which are to be displayed, along with some meta data about the section.
The problem I am having is that each time the user clicks on a tile, which toggles markedForDeletion in the corresponding Image, all of the views in the Grid (which are visible) redraw. This isn't ideal, as in the "real" version of the sample code there is a real penalty to redrawing each Tile, as images must be retrieved and rendered.
I have tried to make Tile conform to Equatable, and then use the .equatable() modifier to notify SwiftUI that the Tile shouldn't be redrawn, but this doesn't work.
Placing another .onAppear outside the Section hints that the entire section is being redrawn each time anything changes, but I'm not sure how to structure my code so that the impact of redrawing expensive Tiles is minimised.
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel: ViewModel = ViewModel()
let columns = [
GridItem(.flexible(minimum: 40), spacing: 0),
GridItem(.flexible(minimum: 40), spacing: 0),
]
var body: some View {
GeometryReader { gr in
ScrollView {
LazyVGrid(columns: columns) {
ForEach(viewModel.imageSections.indices, id: \.self) { imageSection in
Section(header: Text("Section!")) {
ForEach(viewModel.imageSections[imageSection].images.indices, id: \.self) { imageIndex in
Tile(image: viewModel.imageSections[imageSection].images[imageIndex])
.equatable()
.onTapGesture {
viewModel.imageSections[imageSection].images[imageIndex].markedForDeletion.toggle()
}
.overlay(Color.blue.opacity(viewModel.imageSections[imageSection].images[imageIndex].markedForDeletion ? 0.2 : 0))
.id(UUID())
}
}
}
}
}
}
}
}
struct Image {
var id: String = UUID().uuidString
var someData: String
var markedForDeletion: Bool = false
}
struct ImageSection {
var id: String = UUID().uuidString
var images: [Image]
}
class ViewModel: ObservableObject {
#Published var imageSections: [ImageSection] = generateFakeData(numSections: 10, numImages: 10)
}
func generateFakeData(numSections: Int, numImages: Int) -> [ImageSection] {
var sectionsToReturn: [ImageSection] = []
for _ in 0 ..< numSections {
var imageArray: [Image] = []
for i in 0 ..< numImages {
imageArray.append(Image(someData: "Data \(i)"))
}
sectionsToReturn.append(ImageSection(images: imageArray))
}
return sectionsToReturn
}
struct Tile: View, Equatable {
static func == (lhs: Tile, rhs: Tile) -> Bool {
return lhs.image.id == rhs.image.id
}
var image: Image
var body: some View {
Text(image.someData)
.background(RoundedRectangle(cornerRadius: 20).fill(Color.blue))
.onAppear(perform: {
NSLog("Appeared - \(image.id)")
})
}
}
Main reason of global redraw is .id(UUID()), because on every call it force view recreation. But to remove it and keep ForEach operable we need to make model identifiable explicitly.
Here is fixed parts of code. Tested with Xcode 12.1 / iOS 14.1
main cycle
ForEach(Array(viewModel.imageSections.enumerated()), id: \.1) { i, imageSection in
Section(header: Text("Section!")) {
ForEach(Array(imageSection.images.enumerated()), id: \.1) { j, image in
Tile(image: image)
.onTapGesture {
viewModel.imageSections[i].images[j].markedForDeletion.toggle()
}
.overlay(Color.red.opacity(image.markedForDeletion ? 0.2 : 0))
}
}
}
models (note - your Image conflicts with standards SwiftUI Image - avoid such conflicts otherwise you might get into very unclear errors. For demo I renamed it everywhere needed to ImageM)
struct ImageM: Identifiable, Hashable {
var id: String = UUID().uuidString
var someData: String
var markedForDeletion: Bool = false
}
struct ImageSection: Identifiable, Hashable {
var id: String = UUID().uuidString
var images: [ImageM]
}