Custom SwiftUI view does not update when state changes - swiftui

I have modified a custom 5 star rating view (https://swiftuirecipes.com/blog/star-rating-view-in-swiftui) to suite my needs. I use that view in several places in my app to display the current rating for a struct and to change the rating through a selectable list. The problem I have is that when I select a new value for the star rating through the NavigationLink, the underlying rating value changes, but the display does not. I have created a sample app that shows the problem and included it below.
//
// StarTestApp.swift
// StarTest
//
// Created by Ferdinand Rios on 12/20/21.
//
import SwiftUI
#main
struct StarTestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct StarHolder {
var rating: Double = 3.5
}
struct ContentView: View {
#State private var starHolder = StarHolder()
var body: some View {
NavigationView {
NavigationLink {
RatingView(starHolder: $starHolder)
} label: {
HStack {
Text("Rating: \(starHolder.rating, specifier: "%.1f")")
Spacer()
StarRatingView(rating: starHolder.rating, fontSize: 15)
}
.padding()
}
.navigationTitle("Test")
}
}
}
struct RatingView: View {
#Binding var starHolder: StarHolder
var body: some View {
List {
ForEach(0..<11, id: \.self) { index in
HStack {
StarRatingView(rating: Double(index) * 0.5, fontSize: 15)
Spacer()
Image(systemName: starHolder.rating == Double(index) * 0.5 ? "checkmark" : "")
}
.contentShape(Rectangle()) //allows to tap whole area
.onTapGesture {
starHolder.rating = Double(index) * 0.5
}
}
}
.navigationBarTitle(Text("Rating"))
}
}
struct StarRatingView: View {
private static let MAX_RATING: Double = 5 // Defines upper limit of the rating
private let RATING_COLOR = Color(UIColor(red: 1.0, green: 0.714, blue: 0.0, alpha: 1.0))
private let EMPTY_COLOR = Color(UIColor.lightGray)
private let fullCount: Int
private let emptyCount: Int
private let halfFullCount: Int
let rating: Double
let fontSize: Double
init(rating: Double, fontSize: Double) {
self.rating = rating
self.fontSize = fontSize
fullCount = Int(rating)
emptyCount = Int(StarRatingView.MAX_RATING - rating)
halfFullCount = (Double(fullCount + emptyCount) < StarRatingView.MAX_RATING) ? 1 : 0
}
var body: some View {
HStack (spacing: 0.5) {
ForEach(0..<fullCount) { _ in
self.fullStar
}
ForEach(0..<halfFullCount) { _ in
self.halfFullStar
}
ForEach(0..<emptyCount) { _ in
self.emptyStar
}
}
}
private var fullStar: some View {
Image(systemName: "star.fill").foregroundColor(RATING_COLOR)
.font(.system(size: fontSize))
}
private var halfFullStar: some View {
Image(systemName: "star.lefthalf.fill").foregroundColor(RATING_COLOR)
.font(.system(size: fontSize))
}
private var emptyStar: some View {
Image(systemName: "star").foregroundColor(EMPTY_COLOR)
.font(.system(size: fontSize))
}
}
If you run the app, the initial rating will be 3.5 and the stars will show the correct rating. When you select the stars, the RatingView will display with the correct rating checked. Select another rating and return to the ContentView. The text for the rating will update, but the star rating will still be the same as before.
Can anyone point me to what I am doing wrong here? I assume that the StarRatingView would refresh when the starHolder rating changes.

There are a couple of problems here. First, in your RatingView, you are passing a Binding<StarHolder>, but when you update the rating, the struct doesn't show as changed. To fix this, pass in a Binding<Double>, and the change will get noted in ContentView.
The second problem is that StarRatingView can't pick up on the change, so it needs some help. I just stuck an .id(starHolder.rating) onto StarRatingView in ContentView, and that signals to SwiftUI when the StarRatingView has changed so it is updated.
struct ContentView: View {
#State private var starHolder = StarHolder()
var body: some View {
NavigationView {
VStack {
NavigationLink {
RatingView(rating: $starHolder.rating)
} label: {
HStack {
Text("Rating: \(starHolder.rating, specifier: "%.1f")")
Spacer()
StarRatingView(rating: starHolder.rating, fontSize: 15)
.id(starHolder.rating)
}
.padding()
}
.navigationTitle("Test")
}
}
}
}
struct RatingView: View {
#Binding var rating: Double
var body: some View {
List {
ForEach(0..<11, id: \.self) { index in
HStack {
StarRatingView(rating: Double(index) * 0.5, fontSize: 15)
Spacer()
Image(systemName: rating == Double(index) * 0.5 ? "circle.fill" : "circle")
}
.contentShape(Rectangle()) //allows to tap whole area
.onTapGesture {
rating = Double(index) * 0.5
}
}
}
.navigationBarTitle(Text("Rating"))
}
}
One last thing. SwiftUI does not like the "" as an SF Symbol, so I changed the checks to "circle" and "circle.fill". Regardless, you should provide an actual image for both parts of the ternary. Or you could use a "check" and make .opacity() the ternary.

In your StarRatingView change the ForEach(0..<fullCount) {...} etc...
to ForEach(0..<fullCount, id: \.self) {...}.
Same for halfFullCount and emptyCount.
Works well for me.

Related

LazyGridView how to detect and act on item overflowing screen?

I have a grid of items. Each item can expand height. I want to autoscroll when the item is expanded so it doesn't overflow the screen.
I was successful with the following code but I had to revert to a hack.
The idea was to detect when the item is overflowing using a Geometry reader on the item's background. Works wonders.
The issue is that when the view is expanded , the geo reader will update after the condition to check if autoscroll should execute is ran by the dispatcher. Hence my ugly hack.
Wonder what is the proper way ?
import SwiftUI
struct BlocksGridView: View {
private var gridItemLayout = [GridItem(.adaptive(minimum: 300, maximum: .infinity), spacing: 20)]
var body: some View {
ZStack{
ScrollView {
ScrollViewReader { value in
LazyVGrid(columns: gridItemLayout, spacing: 20) {
ForEach((0..<20), id: \.self) {
BlockView(cardID: $0,scrollReader: value).id($0)
}
}
}
.padding(20)
}
}
}
}
struct BlockView : View {
var cardID : Int
var scrollReader : ScrollViewProxy
#State private var isOverflowingScreen = false
#State private var expand = false
var body: some View {
ZStack{
Rectangle()
.foregroundColor(isOverflowingScreen ? Color.blue : Color.green)
.frame(height: expand ? 300 : 135)
.clipShape(Rectangle()).cornerRadius(14)
.overlay(Text(cardID.description))
.background(GeometryReader { geo -> Color in
DispatchQueue.main.async {
if geo.frame(in: .global).maxY > UIScreen.main.bounds.maxY {
isOverflowingScreen = true
} else {
isOverflowingScreen = false
}
}
return Color.clear
})
.onTapGesture {
expand.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // <-- Hack :(
if isOverflowingScreen {
withAnimation{
scrollReader.scrollTo(cardID)
}
}
}
}
}
}
}
struct BlocksGridView_Previews: PreviewProvider {
static var previews: some View {
BlocksGridView()
}
}
Blue items are overflowing ...

SwiftUI `view` should be removed from the super view stack after some time

I would like to implement a toast view in SwiftUI, I am able to do that, but after some time I want to remove the view from the stack. How can we remove current view from the stack?
Here is my code.
// Actual View
struct FormView: View {
var body: some View {
Text("Hello")
.toast() // here is the toast message presentation
}
}
// Toast View
struct ToastMessage<Content: View>: View {
#State var present: Bool = false
let contentView: Content
init( contentView: #escaping () -> Content) {
self.contentView = contentView()
}
var body: some View {
ZStack {
contentView
if present {
HStack {
Text("👋 You're logged in. !!")
.font(.headline)
.foregroundColor(.white)
}
.padding()
.background(Color.black.opacity(0.65))
.cornerRadius(32)
.frame(maxWidth: .infinity, alignment: .center)
.position(x: UIScreen.main.bounds.width * 0.5, y: UIScreen.main.bounds.origin.y + 80)
.transition(.move(edge: .top))
.animation(.spring(response: 0.5, dampingFraction: 1, blendDuration: 2))
}
}
.onAppear {
withAnimation {
present = true
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
present = false
}
}
}
}
}
extension View {
func toast() -> some View {
ToastMessage(contentView: {self})
}
}
Even after 5 seconds, still, this toast view is present in the View's stack. I'm expecting the ToastMessage view should be removed from the FormView after 5 seconds.
Your help would be greatly appreciated and thanks in advance.
Right now, ToastMessage is still in the hierarchy because toast() returns ToastMessage no matter what. Then, within toast message, there's a conditional about whether or not the actual message is displayed.
If you don't want ToastMessage in the hierarchy at all, you'd need to move the #State on level up so that you can use a conditional to determine whether or not it gets displayed.
// Actual View
struct FormView: View {
#State private var showToast = true
var body: some View {
Text("Hello")
.toast(show: $showToast)
.onAppear {
withAnimation { showToast = true }
}
}
}
// Toast View
struct ToastMessage<Content: View>: View {
#Binding var present: Bool
let contentView: Content
init(present: Binding<Bool>, contentView: #escaping () -> Content) {
_present = present
self.contentView = contentView()
}
var body: some View {
ZStack {
contentView
HStack {
Text("👋 You're logged in. !!")
.font(.headline)
.foregroundColor(.white)
}
.padding()
.background(Color.black.opacity(0.65))
.cornerRadius(32)
.frame(maxWidth: .infinity, alignment: .center)
.position(x: UIScreen.main.bounds.width * 0.5, y: UIScreen.main.bounds.origin.y + 80)
.transition(.move(edge: .top))
.animation(.spring(response: 0.5, dampingFraction: 1, blendDuration: 2))
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
withAnimation {
present = false
}
}
}
}
}
extension View {
#ViewBuilder func toast(show: Binding<Bool>) -> some View {
if show.wrappedValue {
ToastMessage(present: show,contentView: {self})
} else {
self
}
}
}
That being said, if you don't want to move the state up, there's not necessarily any harm in keeping ToastMessage in the hierarchy.

SwiftUI: How to pass an argument from one view to the next with dynamically generated buttons?

Problem:
I am unable to force my alpha, beta, or gamma buttons to turn ON when an input parameter is passed from Landing.swift.
I do not understand why when onAppear fires in the stack, the output becomes:
gamma is the title
beta is the title
alpha is the title
gamma is the title
beta is the title
alpha is the title
Confused -> Why is this outputting 2x when the ForEach loop has only 3 elements inside?
Background:
I am trying to pass a parameter from one view (Landing.swift) to another (ContentView.swift) and then based on that parameter force the correct button (in ContentView) to trigger an ON state so it's selected. I have logic shown below in ButtonOnOff.swift that keeps track of what's selected and not.
For instance, there are 3 buttons in ContentView (alpha, beta, and gamma) and based on the selected input button choice from Landing, the respective alpha, beta, or gamma button (in ContentView) should turn ON.
I am dynamically generating these 3 buttons in ContentView and want the flexibility to extend to possibly 10 or more in the future. Hence why I'm using the ForEach in ContentView. I need some help please understanding if I'm incorrectly using EnvironmentObject/ObservedObject or something else.
Maintaining the ON/OFF logic works correctly with the code. That is, if you manually press alpha, it'll turn ON but the other two will turn OFF and so forth.
Thanks for your help in advance! :)
Testing.swift
import SwiftUI
#main
struct Testing: App {
#StateObject var buttonsEnvironmentObject = ButtonOnOff()
var body: some Scene {
WindowGroup {
Landing().environmentObject(buttonsEnvironmentObject)
}
}
}
Landing.swift
import SwiftUI
struct Landing: View {
#State private var tag:String? = nil
var body: some View {
NavigationView {
ZStack{
HStack{
NavigationLink(destination: ContentView(landingChoice:tag ?? ""), tag: tag ?? "", selection: $tag) {
EmptyView()
}
Button(action: {
self.tag = "alpha"
}) {
HStack {
Text("alpha")
}
}
Button(action: {
self.tag = "beta"
}) {
HStack {
Text("beta")
}
}
Button(action: {
self.tag = "gamma"
}) {
HStack {
Text("gamma")
}
}
}
.navigationBarHidden(true)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
var btnName:String
#EnvironmentObject var buttonEnvObj:ButtonOnOff
init(landingChoice:String){
self.btnName = landingChoice
print("\(self.btnName) is the input string")
}
var body: some View {
VStack{
Form{
Section{
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing:10) {
ForEach(0..<buttonEnvObj.buttonNames.count) { index in
BubbleButton(label: "\(buttonEnvObj.buttonNames[index])")
.padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 0))
.onAppear {
print("\(buttonEnvObj.buttonNames[index]) is the title")
}
}
}
}.frame(height: 50)
}
}
}
}
}
struct BubbleButton: View{
#EnvironmentObject var buttonBrandButtons:ButtonOnOff
var label: String
var body: some View{
HStack{
Button(action: {
print("Button action")
buttonBrandButtons.changeState(buttonName: self.label)
}) {
ZStack {
VStack{
HStack {
Spacer()
Text(label)
.font(.system(size: 12,weight:.regular, design: .default))
.foregroundColor(buttonBrandButtons.buttonBrand[self.label]! ? Color.white : Color.gray)
Spacer()
}
}
.frame(height:30)
.fixedSize()
}
}
.background(buttonBrandButtons.buttonBrand[self.label]! ? Color.blue : .clear)
.cornerRadius(15)
.overlay(buttonBrandButtons.buttonBrand[self.label]! ?
RoundedRectangle(cornerRadius: 15).stroke(Color.blue,lineWidth:1) : RoundedRectangle(cornerRadius: 15).stroke(Color.gray,lineWidth:1))
.animation(.linear, value: 0.15)
}
}
}
ButtonOnOff.swift
import Foundation
class ButtonOnOff:ObservableObject{
var buttonNames = ["alpha","beta","gamma"]
#Published var buttonBrand:[String:Bool] = [
"alpha":false,
"beta":false,
"gamma":false
]
func changeState(buttonName:String) -> Void {
for (key,_) in buttonBrand{
if key == buttonName && buttonBrand[buttonName] == true{
buttonBrand[buttonName] = false
} else{
buttonBrand[key] = (key == buttonName) ? true : false
}
}
print(buttonBrand)
}
}
For a short answer just add
.onAppear(){
buttonEnvObj.changeState(buttonName: self.btnName)
}
to ContentView that will highlight the button that was selected.
As for a solution that can be expanded at will. I would suggest a single source of truth for everything and a little simplifying.
struct Landing: View {
#EnvironmentObject var buttonEnvObj:ButtonOnOff
#State private var tag:String? = nil
var body: some View {
NavigationView {
ZStack{
HStack{
NavigationLink(destination: ContentView(), tag: tag ?? "", selection: $tag) {
EmptyView()
}
//Put your buttons here
HStack{
//Use the keys of the dictionary to create the buttons
ForEach(buttonEnvObj.buttonBrand.keys.sorted(by: <), id: \.self){ key in
//Have the button set the value when pressed
Button(action: {
self.tag = key
buttonEnvObj.changeState(buttonName: key)
}) {
Text(key)
}
}
}
}
.navigationBarHidden(true)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
struct ContentView: View {
#EnvironmentObject var buttonEnvObj:ButtonOnOff
var body: some View {
VStack{
Form{
Section{
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing:10) {
//Change this to use the dictionary
ForEach(buttonEnvObj.buttonBrand.sorted(by: {$0.key < $1.key }), id:\.key) { key, value in
BubbleButton(key: key, value: value)
.padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 0))
.onAppear {
print("\(value) is the title")
}
}
}
}.frame(height: 50)
}
}
}
}
}
struct BubbleButton: View{
#EnvironmentObject var buttonBrandButtons:ButtonOnOff
var key: String
var value: Bool
var body: some View{
HStack{
Button(action: {
print("Button action")
buttonBrandButtons.changeState(buttonName: key)
}) {
ZStack {
VStack{
HStack {
Spacer()
Text(key)
.font(.system(size: 12,weight:.regular, design: .default))
.foregroundColor(value ? Color.white : Color.gray)
Spacer()
}
}
.frame(height:30)
.fixedSize()
}
}
.background(value ? Color.blue : .clear)
.cornerRadius(15)
.overlay(value ?
RoundedRectangle(cornerRadius: 15).stroke(Color.blue,lineWidth:1) : RoundedRectangle(cornerRadius: 15).stroke(Color.gray,lineWidth:1))
.animation(.linear, value: 0.15)
}
}
}
class ButtonOnOff:ObservableObject{
//Get rid of this so you can keep the single source
//var buttonNames = ["alpha","beta","gamma"]
//When you want to add buttons just add them here it will all adjust
#Published var buttonBrand:[String:Bool] = [
"alpha":false,
"beta":false,
"gamma":false
]
func changeState(buttonName:String) -> Void {
for (key,_) in buttonBrand{
if key == buttonName && buttonBrand[buttonName] == true{
buttonBrand[buttonName] = false
} else{
buttonBrand[key] = (key == buttonName) ? true : false
}
}
print(buttonBrand)
}
}

Multi selection inside a Grid - SwiftUI

I'm trying to implement a Multi selection inside a dynamic Grid.
With this code, it selects graphically all the elements together, basically they all change color. It saves the data from the selected one, but graphically they are all selected, and I can't select and save more than one at a time.
I think the problem could be the grid, but I also tried to change the grid with other libraries, but it didnt change anything.
Before I had simply an array and it works, now getting the data from the database it is not.
I'm still using and have to use swiftui 1, so I dont have the grid offered in the new swiftui.
I'm using this library for the grid, https://github.com/spacenation/swiftui-grid, the modular grid one, since I have this events that comes from the database.
Thanks.
struct ItemsEventSelectionView: View {
#ObservedObject var eventItems = getEventItems()
#State var eventSelections = [EventItem]()
#State var style2 = ModularGridStyle(.vertical, columns: .min(70), rows: .fixed(40))
#EnvironmentObject var globalDataObservableObject: GlobalDataObservableObject
var body: some View {
ZStack(alignment: Alignment(horizontal: .center, vertical: .top)) {
Group {
Rectangle()
.fill(UIManager.bgGradient)
.frame(minWidth: 0, maxWidth: .infinity)
.edgesIgnoringSafeArea(.all)
}
VStack(alignment: .leading) {
ScrollView(self.style2.axes) {
Grid(self.eventItems.events) { event in
HStack {
MultipleSelectionEvent(title: event.name!, isSelected: self.eventSelections.contains(event)) {
if self.eventSelections.contains(event) {
self.eventSelections.removeAll(where: { $0 == event })
}
else {
self.eventSelections.append(event)
}
}
}
}
}.gridStyle(self.style2)
}
}
}
}
struct MultipleSelectionEvent: View {
var title: String
var isSelected: Bool
var action: () -> Void
var body: some View {
Button(action: self.action) {
HStack{
if self.isSelected {
Text(self.title)
.font(UIManager.einaBodySemibold)
.foregroundColor(UIManager.hBlue)
.padding(.vertical, 7)
.padding(.horizontal, 10)
.background(UIManager.hBlueLight)
.cornerRadius(6)
} else {
Text(self.title)
.font(UIManager.einaBody)
.foregroundColor(UIManager.hDarkBlue)
.padding(.vertical, 7)
.padding(.horizontal, 10)
.background(UIManager.hLightGrey)
.cornerRadius(6)
}
}
}
}
}
class getEventItems : ObservableObject {
let didChange = PassthroughSubject<getEventItems,Never>()
#Published var events = [EventItem]() {
didSet {
didChange.send(self)
}
}
func getEventItems() {
EventItemViewModel().fetchEvents(complete: { (eventItems) in
self.events = eventItems
})
}
init() {
getEventItems()
}
}

Is the SwiftUI tapGesture super greedy?

Swift 5, iOS 13
This code works if I change the second Gesture to say a LONG Press, but leave them both as tap and it never shows the red box? Am I going mad?
import SwiftUI
struct SwiftUIViewQ: View {
#State var swap: Bool = false
var body: some View {
VStack {
if swap {
SquareView(fillColor: Color.red)
.onTapGesture {
self.swap = false
}
} else {
SquareView(fillColor: Color.blue)
.onTapGesture {
self.swap = true
}
}
}
}
}
struct SquareView: View {
#State var fillColor: Color
var body: some View {
Rectangle()
.fill(fillColor)
.frame(width: 128, height: 128)
.onAppear {
print("fillColor \(self.fillColor)")
}
}
}
Oddly if I add an onAppear to the first view, it works... if I than add an onAppear to the second it breaks it again..
var fillColor doesn't need #State, just remove that and it'll work fine
struct SquareView: View {
var fillColor: Color
var body: some View {
Rectangle()
.fill(fillColor)
.frame(width: 128, height: 128)
.onAppear {
print("fillColor \(self.fillColor)")
}
}
}