You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Proposal to make the SwiftUI auth UI customizable at three levels: theming, layout, and per-screen markup, while AuthService keeps owning navigation, MFA resolution, account conflicts, anonymous upgrade, reauthentication, loading state and error reporting.
Most of this is already built. Rounds 1 to 3 below are implemented on auth-picker-view-picker-content and were opened as #1369, which I closed on 2026-07-29 while it was still marked wip. This issue restates that work as a merge-ready design and adds the piece it does not have: per-screen state structs.
Reference implementation for the slot model is FirebaseUI-Android's FirebaseAuthScreen, which ships seven content slots, each receiving a state object.
Problem
On main, AuthPickerView owns the sheet, the NavigationStack, navigator.routes and the navigationDestination(for: AuthView.self) switch, all private. A consumer has three options today: replace a provider's button via registerProvider(providerWithButton:), call renderButtons() and lay them out themselves, or bypass AuthPickerView entirely. The third means reimplementing navigation, MFA resolution, account conflicts, anonymous upgrade and reauth, which the README already lists as the caller's problem.
There is no middle ground where the library keeps driving the flow and the app supplies the pixels.
Principles
Purely additive. Every existing call site compiles and renders identically with no source change.
No AnyView erasure in the slot mechanism. Use the generic-with-constrained-default-init pattern, the same technique SwiftUI uses for .overlay() and .background() chains.
Styling defaults are nil, and nil means today's exact appearance.
Brand-mandated provider button styling stays out of consumer reach. The ProviderStyle presets for Facebook, Apple, Google and Twitter are each provider's required appearance, not a design choice this library made.
Part 1: slots and theming (implemented on the branch)
Both hooks are needed, not one. A .background() chained outside AuthPickerView never reaches the sheet's content because .sheet starts a separate presentation, and a .background() chained only on the root content never reaches pushed screens, because each gets its own opaque backing surface at the UIKit layer. .tint() crosses both boundaries because it is an environment value. .background() does not.
pickerDestination is more general than a fixed slot list: it hands over the AuthView case, so it covers every current route and any route added later without an API change.
Supported by AuthService.registeredProviders (public read-only view of the previously private array), AuthService.triggerSignIn(for:) for generic dispatch, and AuthProviderAction, an opt-in protocol extending AuthProviderUI with triggerAction() async throws for providers whose sign-in produces no credential. PhoneAuthProviderAuthUI conforms. Eight of eleven providers need no protocol change at all, because they already route through the public signIn(_:).
handleProviderSelected centralizes the MFA, account-conflict and error handling that is currently duplicated inline in each provider's own button view, so a custom layout behaves identically to the default one.
1.3 Theming
publicstruct AuthTextFieldStyle: Sendable // tint, containerColor, secondaryColor, errorColor, cornerRadius
publicstruct AuthCTAButtonStyle: Sendable // backgroundColor, contentColor, shape, font
publicstructAuthTypography:Sendable // fontFamily, resolved per Font.TextStyle, Dynamic Type preserved
extension View {func authTextFieldStyle(_ style:AuthTextFieldStyle)-> some View
func authCTAButtonStyle(_ style:AuthCTAButtonStyle)->someViewfunc authTypography(_ typography:AuthTypography)->someView}
All three are environment-injected, so they reach every screen in the subtree including pushed destinations. .authCTAButtonStyle() with no argument is the internal call-site modifier that replaced 21 bare .buttonStyle(.borderedProminent) calls, and .authFont(_:weight:) replaced the bare .font(_:) calls.
Part 2: per-screen state structs (the new part)
.pickerDestination lets a consumer return their own view for .passwordRecovery, but that view starts from nothing. It declares its own @State private var email, runs its own FormValidators, calls authService itself, inspects SignInOutcome for .mfaRequired, forwards to mfaHandler, catches AuthServiceError.accountConflict for accountConflictHandler, and routes the rest to reportError.
That plumbing is what a state struct absorbs, and it is the difference between "replace a screen" meaning presentation only and meaning presentation plus reimplementation.
2.1 Shape
Each default screen view gains a content-slot initializer. The library view stays the state owner, the @State stays exactly where it already is, and only the markup moves out.
This is the same generic-with-constrained-default-init pattern AuthPickerView and AuthPickerContentView already use on the branch, so it is a third application of a reviewed shape rather than a new idea. Every existing EmailAuthView() call site keeps working.
2.2 Two-way fields are Binding, not value plus setter
FirebaseUI-Android models a field as a value plus an onChange closure because that is what Compose's TextField takes. SwiftUI's TextField takes a Binding, so these structs should expose Binding<String> directly. Porting Android's shape literally would push Binding(get:set:) onto every consumer at every field.
// idiomatic
TextField("Email", text: state.email)
// what a literal port would cost
TextField("Email", text:Binding(get:{ state.email }, set: state.onEmailChange))
Actions stay closures, derived values stay let.
2.3 Every struct carries the same three derived values
isLoading, errorMessage and isValid. isValid matters specifically because validation currently lives inside each view as FormValidators calls, so without it a custom view either reimplements the rules or ships a CTA that is always enabled.
2.4 Inventory
One struct per screen, each reflecting the @State that screen already holds.
Reauthentication needs no struct of its own. ReauthenticationCoordinator already sits behind UpdatePasswordView, MFAEnrolmentView and MFAManagementView, so it stays where it is and the consumer never sees it. This is a deliberate divergence from Android, where reauth is a first-class routed screen with its own state object.
signIn is the whole point. It wraps the Task, awaits authService.signIn(email:password:), matches .mfaRequired and calls mfaHandler, catches AuthServiceError.accountConflict and calls accountConflictHandler, sends anything else to reportError, and flips isLoading around all of it. The consumer writes a button.
No @State, no validator, no AuthService, no MFA branch, no error routing.
Part 3: how the layers compose
Three tiers, picked per need, mixable in one app.
Theme only. .authTextFieldStyle, .authCTAButtonStyle, .authTypography, plus .tint and .background through .pickerContent and .pickerDestination.
Layout. AuthPickerContentView(authMethodPicker:) for the method list, .pickerDestination to swap whole routes.
Markup. A screen's content-slot initializer, driven by its state struct.
Tier 3 is per screen, so a consumer can hand-build the phone flow and keep the stock MFA screens, which is the common case and the one that is impossible today.
Non-goals and scope boundaries
Branded provider buttons stay non-themeable. Eight of nine AuthProviderButton( call sites use brand-mandated ProviderStyle presets.
The email-link button is not an AuthProviderUI. It is special-cased inside AuthService.renderButtons() and driven by authService.emailLinkSignInEnabled, so it does not appear in registeredProviders. A custom authMethodPicker layout that wants it must render it separately, exactly as renderButtons() does internally today.
LegacySignInRecoveryView is presented as its own sheet from AuthPickerView rather than as an AuthView route, so it sits outside pickerDestination. Either give it a content slot in the same pass or leave it stock, but do not route it.
No change to AuthProviderUI's existing requirements. AuthProviderAction is additive and opt-in, because AuthProviderUI is public and third parties conform to it.
Sequencing
Fix the two compile errors the automated review caught on wip: Auth picker view picker content #1369: .strikethrough called on a custom view modifier instead of on Text, in AuthTextField.swift and VerificationCodeInputField.swift. Also apply AuthTypography to typed text in SecureField and TextField, not just placeholders.
Bring auth-picker-view-picker-content up to date with current main and land Part 1 as its own PR. It is already built and was verified against a consumer app.
Land per-screen state structs incrementally, one PR per screen or per flow group, each purely additive. Start with EnterPhoneNumberView and EnterVerificationCodeView: two screens, the smallest state, and they prove the Binding ergonomics before the MFA screens, which carry the largest state and have the most to gain.
Add a full-customization sample to the example app supplying custom markup for every screen, matching what FirebaseUI-Android ships. Without it, nothing catches the case where a state struct omits a value the default view reads off authService directly.
Open questions
Should errorMessage be String? or a typed error? Android uses the localized string because the library already owns localization via authService.string.localizedErrorMessage(for:). A typed error lets a consumer branch on the case at the cost of localizing it themselves. Proposal: ship String?, add a typed error: AuthServiceError? alongside it only if a real consumer needs it.
MFAManagementContentState.unenroll(_:) is destructive and can trigger reauthentication. Confirm the coordinator's sheet still presents correctly when the surrounding markup is consumer-supplied.
Does SignedInView belong in the inventory at all? It is the post-auth screen, and a consumer replacing it may prefer to pass their own view to AuthPickerView's trailing closure instead. Cheap to ship, possibly redundant.
Proposal to make the SwiftUI auth UI customizable at three levels: theming, layout, and per-screen markup, while
AuthServicekeeps owning navigation, MFA resolution, account conflicts, anonymous upgrade, reauthentication, loading state and error reporting.Most of this is already built. Rounds 1 to 3 below are implemented on
auth-picker-view-picker-contentand were opened as #1369, which I closed on 2026-07-29 while it was still markedwip. This issue restates that work as a merge-ready design and adds the piece it does not have: per-screen state structs.Reference implementation for the slot model is FirebaseUI-Android's
FirebaseAuthScreen, which ships seven content slots, each receiving a state object.Problem
On
main,AuthPickerViewowns the sheet, theNavigationStack,navigator.routesand thenavigationDestination(for: AuthView.self)switch, all private. A consumer has three options today: replace a provider's button viaregisterProvider(providerWithButton:), callrenderButtons()and lay them out themselves, or bypassAuthPickerViewentirely. The third means reimplementing navigation, MFA resolution, account conflicts, anonymous upgrade and reauth, which the README already lists as the caller's problem.There is no middle ground where the library keeps driving the flow and the app supplies the pixels.
Principles
AnyViewerasure in the slot mechanism. Use the generic-with-constrained-default-init pattern, the same technique SwiftUI uses for.overlay()and.background()chains.nil, andnilmeans today's exact appearance.ProviderStylepresets for Facebook, Apple, Google and Twitter are each provider's required appearance, not a design choice this library made.Part 1: slots and theming (implemented on the branch)
1.1 Route-level substitution
Both hooks are needed, not one. A
.background()chained outsideAuthPickerViewnever reaches the sheet's content because.sheetstarts a separate presentation, and a.background()chained only on the root content never reaches pushed screens, because each gets its own opaque backing surface at the UIKit layer..tint()crosses both boundaries because it is an environment value..background()does not.pickerDestinationis more general than a fixed slot list: it hands over theAuthViewcase, so it covers every current route and any route added later without an API change.1.2 Method picker layout
Supported by
AuthService.registeredProviders(public read-only view of the previously private array),AuthService.triggerSignIn(for:)for generic dispatch, andAuthProviderAction, an opt-in protocol extendingAuthProviderUIwithtriggerAction() async throwsfor providers whose sign-in produces no credential.PhoneAuthProviderAuthUIconforms. Eight of eleven providers need no protocol change at all, because they already route through the publicsignIn(_:).handleProviderSelectedcentralizes the MFA, account-conflict and error handling that is currently duplicated inline in each provider's own button view, so a custom layout behaves identically to the default one.1.3 Theming
All three are environment-injected, so they reach every screen in the subtree including pushed destinations.
.authCTAButtonStyle()with no argument is the internal call-site modifier that replaced 21 bare.buttonStyle(.borderedProminent)calls, and.authFont(_:weight:)replaced the bare.font(_:)calls.Part 2: per-screen state structs (the new part)
.pickerDestinationlets a consumer return their own view for.passwordRecovery, but that view starts from nothing. It declares its own@State private var email, runs its ownFormValidators, callsauthServiceitself, inspectsSignInOutcomefor.mfaRequired, forwards tomfaHandler, catchesAuthServiceError.accountConflictforaccountConflictHandler, and routes the rest toreportError.That plumbing is what a state struct absorbs, and it is the difference between "replace a screen" meaning presentation only and meaning presentation plus reimplementation.
2.1 Shape
Each default screen view gains a content-slot initializer. The library view stays the state owner, the
@Statestays exactly where it already is, and only the markup moves out.This is the same generic-with-constrained-default-init pattern
AuthPickerViewandAuthPickerContentViewalready use on the branch, so it is a third application of a reviewed shape rather than a new idea. Every existingEmailAuthView()call site keeps working.2.2 Two-way fields are
Binding, not value plus setterFirebaseUI-Android models a field as a value plus an
onChangeclosure because that is what Compose'sTextFieldtakes. SwiftUI'sTextFieldtakes aBinding, so these structs should exposeBinding<String>directly. Porting Android's shape literally would pushBinding(get:set:)onto every consumer at every field.Actions stay closures, derived values stay
let.2.3 Every struct carries the same three derived values
isLoading,errorMessageandisValid.isValidmatters specifically because validation currently lives inside each view asFormValidatorscalls, so without it a custom view either reimplements the rules or ships a CTA that is always enabled.2.4 Inventory
One struct per screen, each reflecting the
@Statethat screen already holds.EmailAuthViewEmailAuthContentStateemail,password,confirmPasswordsignIn,signUp,goToPasswordRecovery,goToEmailLink,switchFlowflow: AuthenticationFlow,isValid,isLoading,errorMessagePasswordRecoveryViewPasswordRecoveryContentStateemailsendResetLink,dismissSuccessdidSend,sentEmail,isValid,isLoading,errorMessageEmailLinkViewEmailLinkContentStateemailsendSignInLink,dismissAlertdidSend,isValid,isLoading,errorMessageUpdatePasswordViewUpdatePasswordContentStatepassword,confirmPasswordupdatePasswordisValid,isLoading,errorMessageEnterPhoneNumberViewPhoneNumberContentStatephoneNumber,selectedCountrysendCodeallowedCountries,isValid,isLoading,errorMessageEnterVerificationCodeViewVerificationCodeContentStateverificationCodeverify,resendCode,changeNumberfullPhoneNumber,resendCountdown,isValid,isLoading,errorMessageMFAEnrolmentViewMFAEnrollmentContentStateselectedFactorType,phoneNumber,selectedCountry,verificationCode,totpCode,displayNamesendCode,enroll,copySecretallowedFactors,totpSecret,totpQRCodeURL,didCopySecret,isValid,isLoading,errorMessageMFAResolutionViewMFAResolutionContentStateselectedHintIndex,verificationCode,totpCodesendCode,resolvehints: [MultiFactorInfo],isValid,isLoading,errorMessageMFAManagementViewMFAManagementContentStateunenroll(_:),goToEnrollment,refreshenrolledFactors: [MultiFactorInfo],isLoading,errorMessageSignedInViewSignedInContentStatedisplayNamesignOut,deleteAccount,updatePassword,verifyEmail,goToMFAManagementuser: User?,isEmailVerified,isLoading,errorMessageReauthentication needs no struct of its own.
ReauthenticationCoordinatoralready sits behindUpdatePasswordView,MFAEnrolmentViewandMFAManagementView, so it stays where it is and the consumer never sees it. This is a deliberate divergence from Android, where reauth is a first-class routed screen with its own state object.2.5 Worked example
signInis the whole point. It wraps theTask, awaitsauthService.signIn(email:password:), matches.mfaRequiredand callsmfaHandler, catchesAuthServiceError.accountConflictand callsaccountConflictHandler, sends anything else toreportError, and flipsisLoadingaround all of it. The consumer writes a button.Consumer side:
No
@State, no validator, noAuthService, no MFA branch, no error routing.Part 3: how the layers compose
Three tiers, picked per need, mixable in one app.
.authTextFieldStyle,.authCTAButtonStyle,.authTypography, plus.tintand.backgroundthrough.pickerContentand.pickerDestination.AuthPickerContentView(authMethodPicker:)for the method list,.pickerDestinationto swap whole routes.Tier 3 is per screen, so a consumer can hand-build the phone flow and keep the stock MFA screens, which is the common case and the one that is impossible today.
Non-goals and scope boundaries
AuthProviderButton(call sites use brand-mandatedProviderStylepresets.AuthProviderUI. It is special-cased insideAuthService.renderButtons()and driven byauthService.emailLinkSignInEnabled, so it does not appear inregisteredProviders. A customauthMethodPickerlayout that wants it must render it separately, exactly asrenderButtons()does internally today.LegacySignInRecoveryViewis presented as its own sheet fromAuthPickerViewrather than as anAuthViewroute, so it sits outsidepickerDestination. Either give it a content slot in the same pass or leave it stock, but do not route it.AuthProviderUI's existing requirements.AuthProviderActionis additive and opt-in, becauseAuthProviderUIis public and third parties conform to it.Sequencing
.strikethroughcalled on a custom view modifier instead of onText, inAuthTextField.swiftandVerificationCodeInputField.swift. Also applyAuthTypographyto typed text inSecureFieldandTextField, not just placeholders.auth-picker-view-picker-contentup to date with currentmainand land Part 1 as its own PR. It is already built and was verified against a consumer app.EnterPhoneNumberViewandEnterVerificationCodeView: two screens, the smallest state, and they prove theBindingergonomics before the MFA screens, which carry the largest state and have the most to gain.authServicedirectly.Open questions
errorMessagebeString?or a typed error? Android uses the localized string because the library already owns localization viaauthService.string.localizedErrorMessage(for:). A typed error lets a consumer branch on the case at the cost of localizing it themselves. Proposal: shipString?, add a typederror: AuthServiceError?alongside it only if a real consumer needs it.MFAManagementContentState.unenroll(_:)is destructive and can trigger reauthentication. Confirm the coordinator's sheet still presents correctly when the surrounding markup is consumer-supplied.SignedInViewbelong in the inventory at all? It is the post-auth screen, and a consumer replacing it may prefer to pass their own view toAuthPickerView's trailing closure instead. Cheap to ship, possibly redundant.