SJ cartoon avatar

Ramblings Toggle -> This <- with Cold Indifference

I've tried ViewInspector in the past, but I didn't get very far with it. I wanted something like Testing Library for SwiftUI for component integration testing. Full UI tests are just way too goddamn slow to suffer through for testing if tapping a view checks a box.

What ViewInspector was able to do with Swift's reflection API was impressive, but it was always too much work to just read a string inside a component.

// Not an exact example, but you get the idea
view.inspect().hStack().vStack().forEach().anyView(1).something().somethingElse().view(SomeView.self).text()

Blah... No dice.

BUT! Since then, the find API seems to be a lot more functional (and reliable), and I've changed my approach to use ViewInspector on smaller components, and not so much on larger compositions. It's not too bad, as if I have faith in my low-level design components - the rest are reasonably covered during ViewModel tests and my E2E suites.

The ONE Feature I Needed

Anyways, earlier today I was trying to unit test some custom views in a VERY forms-heavy app (basically, hundreds/thousands/probably millions of checkboxes and radio buttons).

func testIsUnselectedByDefault() throws {
    let sut = VerticalRadioGroup(["a", "b", "c"], selection: .constant(nil))

    let toggles = try sut.inspect().findAll(ViewType.Toggle.self)
    for toggle in toggles {
        let x = try toggle.isOn()
        XCTAssertFalse(try toggle.isOn())
    }
}

XCTAssertFalse failed: threw error "Toggle's tap() and isOn() are currently unavailable for inspection on iOS 16. Situation may change with a minor OS version update. In the meanwhile, please add XCTSkip for iOS 16 and use an earlier OS version for testing."

Sigh. Toggle's tap() and isOn() are currently unavailable for inspection on iOS 16

Alright. It's gonna be one of those days. Just like every other day this week.

Mirror, Mirror

Swift has Reflection? Yeah, I'd offhandedly heard about it, but definitely had never used it. I think the last time I intentionally used any reflection API was back in my C# days over a decade ago.

As ViewInspector makes heavy use of reflection, and how the Swift internals are free to change at any time, I'm not surprised that this toggle API broke.

Whelp, let's take a day and see if we can fix this - because without Toggles being testable, I may as well remove ViewInspector from this project entirely.

An Hour Later

... My first comment on this ticket ...

So, about an hour ago is the first time I'd actually ever seen/used the Swift Mirror system - so I'm in the process of learning how it works.

It looks like the Toggle is now based on a private SwiftUI.ToggleState which I can't seem to access, so I can't just cast to it here (can we?):

return try Inspector.attribute(label: "_toggleState", value: content.view, type: Binding<SwiftUI.ToggleState>.self)

Here is the reflection of that _toggleState:

Optional(SwiftUI.Binding<SwiftUI.ToggleState>(transaction: SwiftUI.Transaction(plist: []), location: SwiftUI.LocationBox<SwiftUI.FunctionalLocation<SwiftUI.ToggleState>>, _value: off))

So, there is a _value which can be used to at least determine if a toggle is toggled or not (and I also tested some workarounds here using styles and whatnot), but I'm not sure if any of this will let anyone affect the value of the toggle.

That _value is extractable a number of ways - but I don't know the type - assuming SwiftUI.ToggleState is an enum though, but I can't strictly type the binding either way.

Again, I know nothing about Mirrors, so maybe there is some way to go in and tell it to change values and have that reflect through the binding that we can't access? Or is there some way to magic cast to private types? I doubt it - but 🤷🏽

My Actual Feelings

As I wrote to a friend:

I would maim for some structural typing in Swift - they use a private type to represent a public type, so even though in reflection I can see the type SwiftUI.ToggleState - that type doesnt exist, and you can't cast into it, or just replace it with the same object in a different namespace

Also rough when through all of github, you're the only one who even knows that a type exists

Github Search for SwiftUI.ToggleState

I Was Wrong!

Thank you SoundCloud, you magnificent bastards. I thought that searching for language:swift ToggleState would be rough, but I really quickly found the solution to my problem (and apparently it was solved 2 years ago!).

I didn't realize you could withUnsafePointer between an internal binding and a different one, but there you go - the more you know.

# From https://github.com/soundcloud/Axt/blob/master/Sources/Axt/Native/Toggle.swift

if #available(iOS 16, *) {
    // Starting with iOS 16, the state of a toggle is no longer a Bool,
    // but an internal enum that can be in an on, off or mixed state.
    let anyToggleStateBinding = digForProperty(named: "_toggleState", in: content)
    let _toggleState = withUnsafePointer(to: anyToggleStateBinding) {
        $0.withMemoryRebound(to: Binding<ToggleState>.self, capacity: 1) {
            $0.pointee
        }
    }
...

private enum ToggleState {
  case on
  case off
  case mixed
}

This quickly led me to this rough solution:

// ViewInspector equivalent of: https://github.com/soundcloud/Axt/blob/master/Sources/Axt/Native/Toggle.swift
let toggleStateBinding = try Inspector.attribute(label: "_toggleState", value: content.view)
let _toggleState = withUnsafePointer(to: toggleStateBinding) {
    $0.withMemoryRebound(to: Binding<ToggleState>.self, capacity: 1) {
        $0.pointee
    }
}

return Binding {
    switch _toggleState.wrappedValue {
    case .on:
        true
    case .off, .mixed:
        false
    }
}

Doing my Part

In the spirit of being a good open-source citizen, I opened a PR to fix this issue for everyone else.

In the end, I went with this variant of the solution - and treating .mixed as falsy, as I did above, and cleaning up the Binding:

let toggleStateBinding = try Inspector.attribute(label: "_toggleState", value: content.view)
let toggleState = withUnsafePointer(to: toggleStateBinding) {
    $0.withMemoryRebound(to: Binding<ToggleState>.self, capacity: 1) {
        $0.pointee
    }
}
return Binding(
    get: { toggleState.wrappedValue == .on },
    set: { toggleState.wrappedValue = $0 ? .on : .off }
)