Final iteration (index) within TabView ForEach not updating properly

Hi all,

I have a bug that I cannot seem to locate. I have extracted from my main app the below minimal reproducible example to illustrate the logic. This code has an expectation that as the user swipes and taps on “Tap to reveal” it will reveal the integer (current index of the ForEach index-based loop) when the isRevealed boolean is false. However, I am running into some unexpected results. It seems when I get to the final ‘card’, it does not show the tap to reveal label and the integer of its preceding card transfers across, which corrects itself when tapped on. To reproduce the problem, please ensure you tap on “Tap to reveal” for every card as you swipe to the end. Any help or pointers will be greatly appreciated :slight_smile:

import SwiftUI

struct IntegerCardView: View {
    
    @State var isRevealed = false
    @State var intHidden = "Tap to reveal"
    
    var body: some View {
        
        GeometryReader { geo in
            
            TabView {
                
                ForEach (0..<4) { index in
                    
                    ZStack {
                        
                        Rectangle()
                        
                            .foregroundColor(.white)
                        
                        Button {
                            
                            self.intHidden = "\(index)"
                            
                        } label: {
                            
                            Text(isRevealed ? "\(index)" : intHidden)
                            
                        }
                        .buttonStyle(.plain)
                        .onAppear {
                            
                            self.intHidden = "Tap to reveal its name"
                            
                        }
                    }
                    .frame(width: geo.size.width * 0.85, height: geo.size.height * 0.5, alignment: .center)
                    .cornerRadius(10)
                    .shadow(color: .black, radius: 10, x: -4, y: 4)
                    
                }
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
            .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .never))
        }
        
    }
}

struct IntCardView_Previews: PreviewProvider {
    static var previews: some View {
        IntegerCardView()
    }
}

The final tab is being loaded by the system before it’s displayed, so the onAppear handler for it fires when the next-to-last tab displays, which means intHidden has the wrong value. You can see this by adding the following code:

//in the Button's action closure:
print("\(index) revealed")

//in the onAppear handler:
print("\(index) appeared")

You’ll see output like this in the console:

0 appeared
0 revealed
1 appeared
1 revealed
2 appeared
3 appeared
2 revealed
3 revealed

The last two lines are what output when I tap the button on the penultimate tab and then the final tab. You can see that by this point, the final tab has already been loaded into memory so its onAppear handler has triggered with an incorrect index value. i.e., when tab 3 is displayed, intHidden is set to 2.

Here’s one way you could fix it:

struct IntegerCardView: View {
    
    @State var isRevealed = false
    @State var intHidden = "Tap to reveal its name"
    @State private var currentTab = 0
    
    var body: some View {
        
        GeometryReader { geo in
            
            TabView(selection: $currentTab) {
                
                ForEach (0..<4) { index in
                    
                    ZStack {
                        
                        Rectangle()
                        
                            .foregroundColor(.white)
                        
                        Button {
                            
                            self.intHidden = "\(index)"
                            
                        } label: {
                            
                            Text(isRevealed ? "\(index)" : intHidden)
                            
                        }
                        .buttonStyle(.plain)
                    }
                    .frame(width: geo.size.width * 0.85, height: geo.size.height * 0.5, alignment: .center)
                    .cornerRadius(10)
                    .shadow(color: .black, radius: 10, x: -4, y: 4)
                }
            }
            .onChange(of: currentTab) { _ in
                intHidden = "Tap to reveal its name"
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
            .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .never))
        }
        
    }
}

Here’s another way, that uses the otherwise unused isRevealed property:

struct IntegerCardView: View {
    
    @State var isRevealed = false
    @State private var currentTab = 0
    let intHidden = "Tap to reveal its name"
    
    var body: some View {
        
        GeometryReader { geo in
            
            TabView(selection: $currentTab) {
                
                ForEach (0..<4) { index in
                    
                    ZStack {
                        
                        Rectangle()
                        
                            .foregroundColor(.white)
                        
                        Button {
                            
                            self.isRevealed = true
                            
                        } label: {
                            
                            Text(isRevealed ? "\(index)" : intHidden)
                            
                        }
                        .buttonStyle(.plain)
                    }
                    .frame(width: geo.size.width * 0.85, height: geo.size.height * 0.5, alignment: .center)
                    .cornerRadius(10)
                    .shadow(color: .black, radius: 10, x: -4, y: 4)
                }
            }
            .onChange(of: currentTab) { _ in
                isRevealed = false
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
            .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .never))
        }
        
    }
}
1 Like

@roosterboy This worked like a charm! Thank you for taking the time to provide these solutions and your explanation has clarified much of my initial confusion :smiley:

Hi @roosterboy, could I trouble you for a follow up on the same snippet of code, albeit updated with your solution from the above but with an added toggle from my app?

When the toggle switch is off (i.e., when isRevealed equals false) and I tap to reveal any integer and immediately follow this up by toggling the switch to hide the integer again, it will not hide it at all until that particular card is swiped to the next. I need toggle switch to alternate between displaying the integer and hiding it without the need to swipe, as it should take effect immediately on whatever card is shown. Appreciate you have resolved my main issue, but feel this is related hence me adding this here. Your help will be appreciated.

import SwiftUI

struct IntegerCardView: View {
    
    @State var isRevealed = false
    @State var intHidden = "Tap to reveal"
    @State private var currentTab = 0
    
    var body: some View {
        
        VStack {
            GeometryReader { geo in
                
                TabView (selection: $currentTab) {
                    
                    ForEach (0..<4) { index in
                        
                        ZStack {
                            
                            Rectangle()
                            
                                .foregroundColor(.white)
                            
                            Button {
                                
                                self.intHidden = "\(index)"
                      
                                
                            } label: {
                                
                                Text(isRevealed ? "\(index)" : intHidden)
                                
                            }
                            .buttonStyle(.plain)
                        }
                        .frame(width: geo.size.width * 0.85, height: geo.size.height * 0.5, alignment: .center)
                        .cornerRadius(10)
                        .shadow(color: .black, radius: 10, x: -4, y: 4)
                        
                    }
                }
                .onChange(of: currentTab) { _ in
                    intHidden = "Tap to reveal its name"
                }
                .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
                .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .never))
            }

            // Toggle held in a preferences page in app but brought in here for MRE
            Toggle("Reveal by default", isOn: $isRevealed)
                .padding(.horizontal, 50)
            
        }
        
    }
}

struct IntCardView_Previews: PreviewProvider {
    static var previews: some View {
        IntegerCardView()
    }
}

Apologies @roosterboy, I believe I have answered my follow-up question using your solution above :sweat_smile:

I used the .onChange directly on my Toggle as below:

Toggle("Animal names shown by default:", isOn: $model.isRevealed)
                                .foregroundColor(.black)
                                .onChange(of: model.isRevealed) { _ in
                                    model.revealName = "Tap to reveal its name"
                                }

Thanks very much again for this!