Skip to content

[PM-35398] feat: Add Binding<Bool> initializer to ExpandableHeaderView#2583

Draft
SaintPatrck wants to merge 2 commits into
vault/pm-35398-ios-promote-expandable-headerfrom
vault/pm-35398-ios-expandable-header-binding-init
Draft

[PM-35398] feat: Add Binding<Bool> initializer to ExpandableHeaderView#2583
SaintPatrck wants to merge 2 commits into
vault/pm-35398-ios-promote-expandable-headerfrom
vault/pm-35398-ios-expandable-header-binding-init

Conversation

@SaintPatrck
Copy link
Copy Markdown
Contributor

@SaintPatrck SaintPatrck commented Apr 24, 2026

🎟️ Tracking

PM-35398

Stacked on #2582.

📔 Objective

ExpandableHeaderView backs its expand/collapse state with an internal @State, which means the user's preference resets whenever the view is recreated. For the upcoming BWPM vault-list section headers, we want that preference to survive app relaunch — that requires caller-owned storage.

Add a second initializer that accepts isExpanded: Binding<Bool> so callers can drive expansion from their own persistence store. The existing (title:count:content:) initializer is preserved unchanged so Authenticator's call site is untouched.

Storage-pattern rationale — why a dual-storage struct with a computed isExpanded: Binding<Bool> rather than a wrapper view or State(initialValue:).projectedValue assign-in-init — is in the feat commit body.

📸 Screenshots

N/A — no call site uses the new initializer yet; Authenticator's existing render path is unchanged.

Adds an additive `isExpanded: Binding<Bool>` initializer to
`ExpandableHeaderView` so callers whose expand/collapse preference must
survive view recreation (e.g., persisted across app launches in PR 6c)
can drive the view from caller-owned storage. The original
zero-ceremony `ExpandableHeaderView(title:count:content:)` initializer
is preserved unchanged, so Authenticator's existing `ItemListView` call
site compiles without modification.

## Storage pattern

Chose option (c) from the brief's "cleanest approach" guidance: dual
storage on a single type. The struct carries both an `@State private
var internalIsExpanded` (used when no binding is supplied) and an
`externalIsExpanded: Binding<Bool>?` (set by the binding init). A
computed `isExpanded: Binding<Bool>` resolves to whichever storage the
initializer populated. This:

- Avoids the brittle `State(initialValue:).projectedValue` assign-in-
  init trick, which loses SwiftUI's state-tracking cell.
- Avoids a public wrapper-view shim (approach a), which would expose
  additional API surface and leak an implementation type into call
  sites.
- Keeps `body` on a single `@Binding` read path.
- Preserves the `ExpandableHeaderView<Content>` identity for both
  initializers — no subtype or factory-function redirection needed.

Authenticator's call site in
`AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift:275`
remains unchanged and still resolves to the no-binding initializer.

## Tests

Adds `ExpandableHeaderView+ViewInspectorTests.swift` covering:

- `test_init_withoutBinding_startsExpanded` — no-binding init renders
  child content on first render (expansion state defaults to true).
- `test_init_withBinding_reflectsBindingState` — binding init reads the
  initial value from the caller's binding (true ⇒ content visible,
  false ⇒ content hidden).
- `test_init_withBinding_updatesThroughBinding` — tapping the header
  writes the toggled value back through the caller-supplied binding.

All three tests pass against BitwardenKit on iPhone 17 / iOS 26.1.
…nd-trip test)

Applies four follow-up fixes from the PR 6b architecture and code reviews.

- I-1 (arch): Document the SwiftUI identity invariant on both initializers.
  A given call site must commit to one initializer variant; switching
  between them at runtime is undefined because SwiftUI preserves the
  view's identity across renders and reuses the internal `@State` cell,
  silently abandoning whichever storage is no longer populated.

- I-3 (arch): Name the default expansion state (`true`) as part of the
  no-binding initializer's public contract, and redirect callers who
  need "collapsed by default" behavior to the binding initializer with
  a pre-initialized `Binding<Bool>(false)`.

- ⚠️1 (code): Add a runtime round-trip test that exercises the header
  button tap repeatedly and asserts each tap writes back through the
  caller's binding. The existing single-tap test was already mediated
  by `ViewType.Button.tap()` (which invokes the action closure —
  including the `withAnimation` wrapper — through ViewInspector rather
  than calling the closure directly), so that test is kept; the new
  `test_init_withBinding_roundTripsAcrossMultipleToggles` guards
  against regressions where the `withAnimation` write stops propagating
  on subsequent toggles (e.g. a future refactor that captures
  `externalIsExpanded` by value).

- ⚠️2 (code): Remove the stale unused `@Previewable @swiftui.State var
  isExpanded = false` from the preview and split the preview into two
  canvases — "Internal state" exercising the no-binding init,
  "Caller-owned binding" exercising the new `Binding<Bool>` init with
  the previously-dead state variable now wired through the binding.

All four tests pass on iPhone 17 / iOS 26.1. `AuthenticatorShared`
still builds unchanged.
@SaintPatrck SaintPatrck added t:feature-app Change Type - Product feature or enhancement app:authenticator Bitwarden Authenticator app context app:password-manager Bitwarden Password Manager app context ai-review-vnext Request a Claude code review using the vNext workflow labels Apr 24, 2026
@github-actions github-actions Bot added t:feature and removed t:feature-app Change Type - Product feature or enhancement labels Apr 24, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.08%. Comparing base (f9e4cd5) to head (7132959).

Additional details and impacted files
@@                               Coverage Diff                                @@
##           vault/pm-35398-ios-promote-expandable-header    #2583      +/-   ##
================================================================================
- Coverage                                         87.15%   86.08%   -1.07%     
================================================================================
  Files                                              1887     2117     +230     
  Lines                                            166760   181612   +14852     
================================================================================
+ Hits                                             145334   156349   +11015     
- Misses                                            21426    25263    +3837     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-vnext Request a Claude code review using the vNext workflow app:authenticator Bitwarden Authenticator app context app:password-manager Bitwarden Password Manager app context t:feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant