1

I have a simple List where each row is a Toggle with its Text and Text as a subtitle all in a VStack. All works fine until I start showing or hiding some rows. Somehow the switch of the Toggle view is misaligned and is placed over its title. This happens only on the device and not when running on the simulator.

enter image description here

It happens with both XCode 13.3 and 13.4 beta on a device running iOS 13.3.1

The complete example is

import SwiftUI

struct ContentView: View {

    @State var showDetails = false
    @State var firstToggle = false
    @State var secondToggle = false
    var body: some View {
        NavigationView {
            Form {
                ToggleSubtitleRow(title: "Show Advanced",
                                     text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, se",
                                     isOn: $showDetails)

                if showDetails {
                    ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                         text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                                         isOn: $firstToggle)

                    ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                         text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                                         isOn: $secondToggle)
                }

            }
            .listStyle(GroupedListStyle())
            .navigationBarTitle("Settings", displayMode: .inline)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

public struct ToggleSubtitleRow: View {
    let title: String
    let text: String
    @Binding var isOn: Bool

    public init(title: String, text: String,
                isOn: Binding<Bool>) {
        self.text = text
        self.title = title
        self._isOn = isOn

    }

    public var body: some View {
        VStack(alignment: .leading) {
            Toggle(isOn: $isOn) {
                Text(title)
            }
            Text(text)
                .fixedSize(horizontal: false, vertical: true)
                .foregroundColor(Color(.secondaryLabel))
                .frame(alignment: .leading)
        }
        .foregroundColor(Color(.label))
    }
}
Jan
  • 7,444
  • 9
  • 50
  • 74

1 Answers1

1

this should work

import SwiftUI

struct ContentView: View {

    @State var showDetails = false
    @State var firstToggle = false
    @State var secondToggle = false
    var body: some View {
        NavigationView {
            Form {
                ToggleSubtitleRow(title: "Show Advanced",
                                     text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, se",
                                     isOn: $showDetails)

                if showDetails {
                    ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                         text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                        isOn: $firstToggle).id(UUID())

                    ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                         text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                                         isOn: $secondToggle)
                }

            }
            .listStyle(GroupedListStyle())
            .navigationBarTitle("Settings", displayMode: .inline)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

public struct ToggleSubtitleRow: View {
    let title: String
    let text: String
    @Binding var isOn: Bool

    public var body: some View {
        VStack(alignment: .leading) {
            Toggle(isOn: $isOn) {
                Text(title)
            }
            Text(text)
                .fixedSize(horizontal: false, vertical: true)
                .foregroundColor(Color(.secondaryLabel))
                .frame(alignment: .leading)
        }
        .foregroundColor(Color(.label))
    }
}

I put it here to show, that the whole conditional part is recreated if at least one of its element need to be recreated.

For explanation, change the code a little bit (without any .id modifier)

if showDetails {
    Text("\(showDetails.description)")
    ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                  text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                                  isOn: $firstToggle)

     ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                  text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                                  isOn: $secondToggle)
 }

It works, "as expected", because in the conditional part SwiftUI recognized "something" was changed.

Text("\(showDetails.description)")

has the same effect.

What about .id modifier? Why it works?

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {

    /// Returns a view whose identity is explicitly bound to the proxy
    /// value `id`. When `id` changes the identity of the view (for
    /// example, its state) is reset.
    @inlinable public func id<ID>(_ id: ID) -> some View where ID : Hashable

}

Based on written

ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                  text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                    isOn: $firstToggle).id(showDetails)

works as well!

Lets rearrange the code this way

struct ContentView: View {

    @State var showDetails = false
    @State var firstToggle = false
    @State var secondToggle = false
    var body: some View {
        let g = Group {
            if showDetails {
                ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                  text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                                  isOn: $firstToggle).id(UUID())

                ToggleSubtitleRow(title: "Lorem ipsum dolor sit amet",
                                  text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam",
                                  isOn: $secondToggle)
            }
        }
        let f = Form {
            ToggleSubtitleRow(title: "Show Advanced",
                                 text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, se",
                                 isOn: $showDetails)
            g

        }
        .listStyle(GroupedListStyle())
        .navigationBarTitle("Settings", displayMode: .inline)
        let v = NavigationView {
            f
        }
        return v
    }
}

and check the type of g

let g: Group<TupleView<(some View, ToggleSubtitleRow)>?>

we can see how SwiftUI deal with our "conditional". It is in fact

TupleView<(some View, ToggleSubtitleRow)>?

UPDATE based on discussion, applying .id modifier on more than one ToggleSubtitleRow simply doesn't work

The best option, how to solve this bug is redefine

public struct ToggleSubtitleRow: View {
    let title: String
    let text: String
    @Binding var isOn: Bool

    public var body: some View {
        VStack(alignment: .leading) {
            Toggle(isOn: $isOn) {
                Text(title)
            }.id(UUID())
            Text(text)
                .fixedSize(horizontal: false, vertical: true)
                .foregroundColor(Color(.secondaryLabel))
                .frame(alignment: .leading)
        }
        .foregroundColor(Color(.label))
    }
}

by not modifying anything in you ContentView but directly Toggle self in ToggleSubtitleRow

user3441734
  • 16,722
  • 2
  • 40
  • 59
  • That indeed works. So only *one* of the conditional rows needs to have this UUID? That's very strange. What is the reasoning behind? – Jan Feb 16 '20 at 20:18
  • @Jan i added some more explanation – user3441734 Feb 16 '20 at 20:34
  • @Jan the confusion went from the conditional part. You have to understand, that this is not swift conditional, but SwiftUI conditional, even though it seems to be the same, it is totally different construct :-) No swift expression is allowed there! – user3441734 Feb 16 '20 at 20:46
  • I understand that SwiftUI re-creates the view based on the state. How come that setting the uuid on each of the detail views produces the same unwanted behaviour? – Jan Feb 16 '20 at 21:36
  • what is detail view? and be careful, using .id modifier the state will be reset! and see, that UUID() is not what you need, i show you how to use showDetails to reset the state. if you apply the same for more ToggleSubtitleRow, it doesn't work! SwiftUI is not able to distinguish between them! – user3441734 Feb 16 '20 at 21:43
  • @Jan i updated my answer with some suggestion. It is still some "workaround" but finally very well isolated (and as such, it is easy to change it for any future release of SwiftUI) – user3441734 Feb 16 '20 at 22:29
  • "what is detail view?" I meant the `ToggleSubtitleRow`s being shown a the `Show Advanced` is enabled. The latest version with the .uid on the `Toggle` itself seems to work. Thanks! – Jan Feb 17 '20 at 07:56