Ferdinand Rios
Ferdinand Rios

Reputation: 1192

Custom SwiftUI view does not update when state changes

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.

Upvotes: 0

Views: 434

Answers (2)

dudette
dudette

Reputation: 856

In your StarRatingView change the ForEach(0..<fullCount) {...} etc... to ForEach(0..<fullCount, id: \.self) {...}.

Same for halfFullCount and emptyCount.

Works well for me.

Upvotes: 1

Yrb
Yrb

Reputation: 9675

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.

Upvotes: 2

Related Questions