SwiftUI - Environment Object Not Updating UI - ios

My app has one button that produces either a green or red background. The user presses the button over and over to generate green or red each time and the results are recorded into an Observable Object called "Current Session." The current totals are displayed in a custom view made of capsules. For some reason, the view will not update the totals and keeps displaying 0.
I've tried adding a state binding for numberOfGreens in ResultsBar but that doesn't work either. It still just shows 0 every time. The environment has been added to all of my views. I've tried printing the results and the result numbers show up correct in the console but the UI will not update. Interestingly, the trialsRemaining variable works perfectly on other screens in my app, having no trouble updating when it has to. Also, ResultsBar works perfectly fine in preview.
class CurrentSession: ObservableObject {
#Published var trialsRemaining: Int = 100
#Published var numberOfGreens: Int = 0
#Published var numberOfReds: Int = 0
func generateGreenOrRed() {
...
if resultIsGreen {numberOfGreens += 1}
else if resultIsRed {numberOfReds += 1}
trialsRemaining -= 1
}
}
struct ResultsBar: View {
let totalBarWidth: CGFloat = UIScreen.main.bounds.width - 48
var onePercentOfTotalWidth: CGFloat { return totalBarWidth/100 }
var numberOfGreens: Int
var body: some View {
ZStack(alignment: .leading) {
Capsule()
.frame(width: totalBarWidth, height: 36)
.foregroundColor(.red)
.shadow(radius: 4)
Capsule()
.frame(width: (onePercentOfTotalWidth * CGFloat(numberOfGreens)), height: 36)
.foregroundColor(.green)
.shadow(radius: 4)
}
}
}
struct ResultsView: View {
#EnvironmentObject var session: CurrentSession
var body: some View {
VStack {
HStack {
Text("\(session.numberOfGreens)")
Spacer()
Text("\(session.numberOfReds)")
}
ResultsBar(numberOfGreens: session.numberOfGreens)
}
}
}
I am running Xcode 11.2 beta 2 (11B44) on macOS Catalina 10.15.2 Beta (19C32e).

Are you passing a reference to a CurrentSession instance to your ResultsView? If I copy your code, the following does update the ResultsBar when I click on it.
struct ContentView: View {
#ObservedObject var session = CurrentSession()
var body: some View {
ResultsView()
.environmentObject(session)
.onTapGesture {
self.session.generateGreenOrRed()
}
}
}

I had this same issue, but my issue was because I was updating the environment object on a background thread because I'm using an api. In order to fix my issue I just needed to wrapped setting my EnvironmentObject vars like the following.
DispatchQueue.main.async{
self.numberOfGreens = GetNumberOfGreens(id: id)
}
I hope this helps someone.

Related

SwiftUI: Scrollview with both axes enabled produces layout mess

The Problem
In our brand new SwiftUI project (Multiplatform app for iPhone, iPad and Mac) we are using a ScrollView with both .horizontal and .vertical axes enabled. In the end it all looks like a spreadsheet.
Inside of ScrollView we are using LazyVStack with pinnedViews: [.sectionHeaders, .sectionFooters]. All Footer, Header and the content cells are wrapped into a LazyHStack each. The lazy stacks are needed due to performance reasones when rows and columns are growing.
And this exactly seems to be the problem. ScrollView produces a real layout mess when scrolling in both axes at the same time.
Tested Remedies
I tried several workarounds with no success.
We built our own lazy loading horizontal stack view which shows only the cells whose indexes are in the visible frame of the scrollview, and which sets dynamic calculated leading and trailing padding.
I tried the Introspect for SwiftUI packacke to set usesPredominantAxisScrolling for macOS and isDirectionalLockEnabled for iOS. None of this works as expected.
I also tried a workaround to manually lock one axis when the other one is scrolling. This works fine on iOS but it doesn't on macOS due to extreme poor performance (I think it's caused by the missing NSScrollViewDelegate and scroll events are being propagated via NotificationCenter).
How does it look?
To give you a better idea of what I mean, I have screenshots for both iOS and macOS.
Here you can watch short screen recording for both iOS and macOS.
Example Code
And this is the sample code used for the screenshots. So you can just create a new Multiplatform project and paste the code below in the ContentView.swift file. That's all.
import SwiftUI
let numberOfColums: Int = 150
let numberOfRows: Int = 350
struct ContentView: View {
var items: [[Item]] = {
var items: [[Item]] = [[]]
for rowIndex in 0..<numberOfRows {
var row: [Item] = []
for columnIndex in 0..<numberOfColums {
row.append(Item(column: columnIndex, row: rowIndex))
}
items.append(row)
}
return items
}()
var body: some View {
Group {
ScrollView([.horizontal, .vertical]) {
LazyVStack(alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders, .sectionFooters]) {
Section(header: Header()) {
ForEach(0..<items.count, id: \.self) { rowIndex in
let row = items[rowIndex]
LazyHStack(spacing: 1) {
ForEach(0..<row.count, id: \.self) { columnIndex in
Text("\(columnIndex):\(rowIndex)")
.frame(width: 100)
.padding()
.background(Color.gray.opacity(0.2))
}
}
}
}
}
}
.edgesIgnoringSafeArea([.top])
}
.padding(.top, 1)
}
}
struct Header: View {
var body: some View {
LazyHStack(spacing: 1) {
ForEach(0..<numberOfColums, id: \.self) { idx in
Text("Col \(idx)")
.frame(width: 100)
.padding()
.background(Color.gray)
}
Spacer()
}
}
}
struct Item: Hashable {
let id: String = UUID().uuidString
var column: Int
var row: Int
}
Can You Help?
Okay, long story short...
Does anybody know a real working solution for this issue?
Is it a known SwiftUI ScrollView bug?
If there were a way to lock one scrolling direction while the other one is being scrolled, then I could deal with that. But at the moment, unfortunately, it is not usable for us.
So any solution is highly appreciated! šŸ˜ƒ
Is there any reason you don't want to use a Grid for this layout?
Here is the article I would suggest you read to solve this problem:
https://swiftui-lab.com/impossible-grids/

SwiftUI animation problem with a binding to a StateObject inside a NavigationView

I have an interesting situation in regards to animations in SwiftUI. In its simplified form, I have a view that shows a rectangle which, when tapped, should toggle a Bool binding and represent the change with an animated transition of color. But it seems like the animation doesn't happen when the view is inside a NavigationView and the binding is coming from a StateObject instead of simple local state. I can't explain why that would be the case, and would appreciate any thoughts.
Below is the code that shows a simplified case that reproduces the issue. The app code isn't particularly interesting; it's the default code that creates a WindowGroup and shows an instance of ContentView in it.
import SwiftUI
class AppState: ObservableObject {
#Published var isRed = false
}
struct RectView: View {
#Binding var isRed: Bool
var body: some View {
Rectangle()
.fill(isRed ? Color.red : Color.gray)
.frame(width: 75, height: 75, alignment: .center)
.onTapGesture {
withAnimation(.easeInOut(duration: 1)) {
isRed.toggle()
}
}
}
}
struct ContentView: View {
#StateObject var appState = AppState()
#State private var childViewIsRed = false
var body: some View {
VStack {
NavigationView {
List {
NavigationLink("Link with binding to state object", destination: RectView(isRed: $appState.isRed))
NavigationLink("Link with binding to state variable", destination: RectView(isRed: $childViewIsRed))
}
}
.frame(height: 300)
RectView(isRed: $appState.isRed)
RectView(isRed: $childViewIsRed)
}
}
}
The gif/video below is me demonstrating four things, tapping on these views from bottom to top:
First I tap on the very bottom rectangle - the one with a binding to a #State property. It toggles with animation as expected. I tap again to leave it gray.
Then I tap the second rectangle from the bottom - one with a binding to a #Published property in the #StateObject. All is well.
Next, I tap on the NavigationLink that leads to a rectangle that is bound to the local #State property. When I tap on the rectangle the transition is animated fine. The very bottom rectangle also animates, which makes sense since they are bound to the same property.
Finally I tap on the top NavigationLink, which leads to a rectangle bound to the #Published property in the #StateObject. When I tap on this rectangle though, there is no animation. The rectangle snaps to red. The rectangle below it (which is bound to the same property) animates fine, proving that the property is indeed toggled. But there is no animation inside the NavigationView. Why? What am I missing?
I've searched for existing questions. I'm aware that there are some around NavigationView (like this) but that doesn't explain why one type of binding would work fine inside a NavigationView and another wouldn't. Similarly, there are ones around animating changes to #ObservedObjects (like this, would a #StateObject be similar?) but I don't follow why animating changes to such a binding would work fine outside of a NavigatonView and not inside one.
In case it's relevant I'm using Xcode 13.2.1, running on macOS 12.2.1, which is in turn running on a 16-inch M1 Max MacBook Pro. The simulator shown in the gif/video is an iPhone 11 Pro Max. I get the same results if I deploy this tiny test app to a physical iPhone 7 running iOS 15.3.1.
To be honest with you, I am not sure why, but utilizing the animation modifier on the RectView allows the animation to occur without any issues.
struct RectView: View {
#Binding var isRed: Bool
var body: some View {
Rectangle()
.fill(isRed ? Color.red : Color.gray)
.frame(width: 75, height: 75, alignment: .center)
.animation(.easeOut(duration: 1), value: isRed)
.onTapGesture { isRed.toggle() }
}
}
screen recording example

PageTabVIewStyle() crashes the canvas preview

I'm creating a small SwiftUI app with a TabView, but the code stopped working as soon as I used the modifier .tabViewStyle(PageTabViewStyle()). Why is that? The app is meant to cycle through a series of images and is going to be similar in design to the Photos app. It's based on a Kavsoft tutorial, but that tutorial used deprecated code. When I used the more modern equivalent, the app's preview stopped working, and I can no longer see the TabView.
struct Home: View {
/* Posts */
#State var posts: [Post] = []
/* Cuurent Image */
#State var currentImage = ""
var body: some View {
/* Gallery */
TabView(selection: $currentImage) {
ForEach(posts) { post in
/* GeometryReader to get screen sizes for image */
GeometryReader { geo in
let size = geo.size
Image(post.image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size.width, height: size.height)
.cornerRadius(0)
}
.edgesIgnoringSafeArea(.all)
}
}
.tabViewStyle(PageTabViewStyle()) // Cannot work
.onAppear {
for index in 1...10 {
posts.append(Post(image: "\(index)"))
}
}
}
}
struct Post: Identifiable, Hashable {
var id = UUID().uuidString
var image: String
}
Adding an answer here for completeness, I suspected it would work with static content like Text(). This was crashing because ForEach is trying to load posts that don't exist yet, causing it to init from an empty array. For the tab view to work correctly, it needs at least one rendered view.
To fix this, posts needs to have at least one element in it to render.

How to display image except for one item on Foreach loop in swiftUI?

I've created an card and I want an image to display on top of each card except for the first one. please help me to solve this.
This is the struct that holds the image.
struct XButton: View {
var body: some View {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .bold))
.foregroundColor(.white)
.padding(.all, 10)
.background(Color.black.opacity(0.6))
.mask(Circle())
}
}
This the scrollview that contains the cards inside the foreach loop
ScrollView{
ForEach(CardsData) { item in
VStack {
CardsView(Cards: item)
}
}
}
You can use enumerated() to get the index and item during a ForEach. Then, XButton is only rendered conditionally if index == 0
Then, I use ZStack to put the XButton on top. I'm assuming "on top" in your original question means overlaid, but if you just meant higher on the y axis, you could change this back to a VStack
struct ContentView : View {
var body: some View {
ScrollView{
ForEach(Array(CardsData.enumerated()), id: \.1.id) { (index,item) in
ZStack {
CardsView(Cards: item)
if index == 0 {
XButton()
}
}
}
}
}
}
Note: this is assuming that CardsData items conform to Identifiable, which I assume they did if your original ForEach worked. Note that I'm getting the id from .1.id which is the id property of the item in the CardsData which is now a tuple where the first part (0) is the index and the second (1) is the item.

How to have 1 column in a multiple column list be of the same width w/out using a frame modifier of width so to retain flexibility

I have a list of entries that consist of multiple columns of UI with all except the first free to be uniquely sized horizontally (i.e. theyā€™re as short/long as their content demands). I know with the first consistently sized column I can set a frame modifier width to achieve this, but I was hoping there is a better and more flexible way to get the desired behaviour. The reason being I donā€™t believe the solution is optimised to consider the userā€™s display size nor the actual max content width of the columns. That is, the width set will either not be wide enough when the display size is set to the largest, or, if it is, then it will be unnecessarily wide on a smaller/regular display size.
This is my current best attempt:
GeometryReader { geometry in
VStack {
HStack {
HStack {
Text("9am")
Image(systemName: "cloud.drizzle").font(Font.title2)
.offset(y: 4)
}.padding(.all)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
VStack {
HStack {
Text("Summary")
.padding(.trailing, 4)
.background(Color.white)
.layoutPriority(1)
VStack {
Spacer()
Divider()
Spacer()
}
VStack {
Text("12Ā°")
Text("25%")
.foregroundColor(Color.black)
.background(Color.white)
}.offset(y: -6)
Spacer()
}.frame(width: geometry.size.width/1.5)
}
Spacer()
}
HStack {
HStack {
Text("10am")
.customFont(.subheadline)
Image(systemName: "cloud.drizzle").font(Font.title2)
.offset(y: 4)
.opacity(0)
}
.padding(.horizontal)
.padding(.vertical,4)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
VStack {
HStack {
ZStack {
Text("Mostly cloudy")
.customFont(.body)
.padding(.trailing, 4)
.background(Color.white)
.opacity(0)
VStack {
Spacer()
Divider()
Spacer()
}
}
VStack {
Text("13Ā°")
Text("25%")
.foregroundColor(Color.black)
.background(Color.white)
}.offset(y: -6)
Spacer()
}.frame(width: geometry.size.width/1.75)
}
Spacer()
}
}
}
For me, this looks like:
As you can tell, 10 am is slightly wider than 9 am. To keep them as closely sized as possible, Iā€™m including a cloud icon in it too, albeit with zero opacity. Ideally, 10 am would be sized the same as 9 am without needing a transparent cloud icon. More generally speaking, what would make sense is the widest HStack in this column is identified and then whatever its width is will be applied to all other columns. Keep in mind, my code above is static for demo purposes. It will be a view that is rendered iterating through a collection of rows.
You can use dynamic frame modifiers, such as frame(.maxWidth: .infinity) modifier to extend views so that they fill up the entire frame, even if the frame is dynamic. Here is an example that should help you get going:
struct CustomContent: View {
var body: some View {
VStack {
VStack {
CustomRow(timeText: "9am", systemIcon: "cloud.drizzle", centerText: "Summary", temperature: "12Ā°", percent: "25%")
CustomRow(timeText: "10am", systemIcon: nil, centerText: nil, temperature: "13Ā°", percent: "25%")
}
VStack {
CustomRow(timeText: "9am", systemIcon: "cloud.drizzle", centerText: "Summary", temperature: "12Ā°", percent: "25%")
CustomRow(timeText: "10am", systemIcon: nil, centerText: nil, temperature: "13Ā°", percent: "25%")
}
.frame(width: 300)
}
}
}
struct CustomContent_Previews: PreviewProvider {
static var previews: some View {
CustomContent()
}
}
struct CustomRow: View {
let timeText: String
let systemIcon: String?
let centerText: String?
let temperature: String
let percent: String
var body: some View {
HStack {
//Left column
HStack(alignment: .center) {
Text(timeText)
if let icon = systemIcon {
Image(systemName: icon)
.font(.title2)
}
}
.padding(.all)
.frame(width: 105, height: 60)
.background(Color.blue.opacity(0.2))
.cornerRadius(16)
// Center column
ZStack(alignment: .leading) {
Capsule()
.fill(Color.black.opacity(0.3))
.frame(height: 0.5)
if let text = centerText {
Text(text)
.lineLimit(1)
.background(Color.white)
}
}
// Right column
VStack {
Text(temperature)
Text(percent)
.foregroundColor(Color.black)
}
}
}
}
Guided by https://www.wooji-juice.com/blog/stupid-swiftui-tricks-equal-sizes.html, I accomplished this.
This is the piece of UI I want to make sure is horizontally sized equally across all rows with the width set to whatever is the highest:
HStack {
VStack {
Spacer()
Text("9am")
Spacer()
}
}.frame(minWidth: self.maximumSubViewWidth)
.overlay(DetermineWidth())
The stack the above is contained in has an OnPreferenceChange modifier:
.onPreferenceChange(DetermineWidth.Key.self) {
if $0 > maximumSubViewWidth {
maximumSubViewWidth = $0
}
}
The magic happens here:
struct MaximumWidthPreferenceKey: PreferenceKey
{
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat)
{
value = max(value, nextValue())
}
}
struct DetermineWidth: View
{
typealias Key = MaximumWidthPreferenceKey
var body: some View
{
GeometryReader
{
proxy in
Color.clear
.anchorPreference(key: Key.self, value: .bounds)
{
anchor in proxy[anchor].size.width
}
}
}
}
The link at the top best describes eachā€™s purpose.
MaximumWidthPreferenceKey
This defines a new key, sets the default to zero, and as new values get added, takes the widest
DetermineWidth
This view is just an empty (Color.clear) background, but with our new preference set to its width. Weā€™ll get back to that clear background part in a moment, but first: there are several ways to set preferences, here, weā€™re using anchorPreference. Why?
Well, anchorPreference has ā€œNo Overview Availableā€ so I donā€™t actually have a good answer for that, other than it seems to be more reliable in practice. Yeah, cargo-cult code. Whee! I have a hunch that, what with it taking a block and all, SwiftUI can re-run that block to get an updated value when there are changes that affect layout.
Another hope I have is that this stuff will get better documented, so that we can better understand how these different types are intended to be used and new SwiftUI developers can get on board without spending all their time on Stack Overflow or reading blog posts like this one.
Anyway, an anchor is a token that represents a dimension or location in a view, but it doesnā€™t give you the value directly, you have to cash it in with a GeometryProxy to get the actual value, so, thatā€™s what we did ā€” to get the value, you subscript a proxy with it, so proxy[anchor].size.width gets us what we want, when anchor is .bounds (which is the value we passed in to the anchorPreference call). Itā€™s kind of twisted, but it gets the job done.
maximumSubViewWidth is a binding variable passed in from the parent view to ensure the maximumSubViewWidth each subview refers to is always the the up-to-date maximum.
ForEach(self.items) { item, in
ItemSubview(maximumSubViewWidth: $maximumSubViewWidth, item: item)
}
The one issue with this was there was an undesired subtle but still noticeable animation on the entire row with any UI that gets resized to the max width. What I did to work around this is add an animation modifier to the parent container thatā€™s nil to start with that switches back to .default after an explicit trigger.
.animation(self.initialised ? .default : nil)
I set self.initialised to be true after the user explicitly interacts with the row (In my case, they tap on a row to expand to show additional info) ā€“ this ensured the initial animation doesn't incorrectly happen but animations go back to normal after that. My original attempt toggled initialised's state in the .onAppear modifier so that the change is automatic but that didn't work because Iā€™m assuming resizing can occur after the initial appearance.
The other thing to note (which possibly suggests although this solution works that it isn't the best method) is I'm seeing this message in the console repeated for either every item, or just the ones that needed to be resized (unclear but the total number of warnings = number of items):
Bound preference MaximumWidthPreferenceKey tried to update multiple
times per frame.
If anyone can think of a way to achieve the above whilst avoiding this warning then great!
UPDATE: I figured the above out.
Itā€™s actually an important change because without addressing this I was seeing the column keep getting wider on subsequent visits to the screen.
The view has a new widthDetermined #State variable thatā€™s set to false, and becomes true inside .onAppeared.
I then only determine the width for the view IF widthDetermined is false i.e. not set. I do this by using the conditional modifier proposed at https://fivestars.blog/swiftui/conditional-modifiers.html:
func `if`<Content: View>(_ conditional: Bool, content: (Self) -> Content) -> TupleView<(Self?, Content?)> {
if conditional { return TupleView((nil, content(self))) }
else { return TupleView((self, nil)) }
}
and in the view:
.if(!self.widthDetermined) {
$0.overlay(DetermineWidth())
}
I had similar issue. My text in one of the label in a row was varying from 2 characters to 20 characters. It messes up the horizontal alignment as you have seen. I was looking to make this column in row as fixed width. I came up with something very simple. And it worked for me.
var body: some View { // view for each row in list
VStack(){
HStack {
Text(wire.labelValueDate)
.
.
.foregroundColor(wire.labelColor)
.fixedSize(horizontal: true, vertical: false)
.frame(width: 110.0, alignment: .trailing)
}
}
}

Resources