Skip to content

[Config-driven linear onboarding dialogs] Extract render engine and controllers - #9365

Open
LukasPaczos wants to merge 28 commits into
developfrom
refactor/lpaczos/config-driven-linear-onboarding-dialogs/renderer
Open

[Config-driven linear onboarding dialogs] Extract render engine and controllers#9365
LukasPaczos wants to merge 28 commits into
developfrom
refactor/lpaczos/config-driven-linear-onboarding-dialogs/renderer

Conversation

@LukasPaczos

@LukasPaczos LukasPaczos commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Task/Issue URL: https://app.asana.com/1/137249556945/project/1208671518894266/task/1216978525560082?focus=true
Tech Design URL (if applicable): https://app.asana.com/1/137249556945/project/1208671518894266/task/1216854264994244?focus=true
API Proposals URL(s) (if applicable):

Description

Step 2 of the config-driven onboarding dialogs work.

  • Each onboarding step is now described as a value-comparable DialogConfig.
  • A slim ViewModel resolves the orchestrator's current step into the config, and DialogRenderEngine diffs it
    against the previous config to drive seven collaborators — one per visual axis, plus the shared card
    choreography.
    • The VM already contains some Fragment callbacks and commands that are directly copy-pasted from the legacy impl and currently unused. These will be used by screens migrated in follow-ups.
  • COMPARISON_CHART (stateless, with a bespoke content entrance) and ADDRESS_BAR_POSITION (stateful) are
    ported end to end. The whole arm sits behind OnboardingBrandDesignUpdateToggles#configDrivenDialogs. The
    legacy renderer and its ViewModel are untouched.
  • Unported steps (including intro animation) auto-advance and apply default side effects (settings writes, click pixels).
  • "Presented" pixels are not universally sent, will be addressed when all steps are ported.

Divergences from the tech design

Change Why
ContentHandle gains onContentReady alongside afterFade afterFade is an Animator the engine owns end to end. Some entrance work can't be modelled that way (an unbounded loop, or animation driven outside the animator framework, for example INPUT_SCREEN's toggle animation), so it needs a side-effect hook that still runs exactly once per render on the animated, snapped and skipped paths.
Added CardArrowController TD already defined DialogConfig.cardArrow, so this is the place for that logic.
Interactive content revealed in afterFade/onContentReady must be gated by the binder The design assumed isAnimatinginterceptChildTouches covered the whole entrance. It doesn't: interception is released as afterFade starts (so that the already visible CTA buttons are clickable). Anything interactive revealed in afterFade is tappable while invisible (for example, INPUT_SCREEN_PREVIEW step's suggestion buttons), so binders need to gate that directly. Documented on ContentHandle and will impact only the INPUT_SCREEN_PREVIEW step later on.

Behaviour changes vs the legacy renderer (ported parts)

Change Detail
When content animations are skipped, so are background/Dax In legacy, skipping animations meant only snapping card content into place while background/Dax animations kept playing. This is aligned now but to be confirmed with @mikescamell if the original behavior wasn't intentional.
Card anchoring has a single owner Legacy's vertical bias on tablets disagreed between steps (bias 0.5 in quick setup and add-to-dock, 0 elsewhere), and the fit corrector overrode them again whenever a decoration stopped fitting. CardAnchorResolver now aligns the bias across all steps. To let the resolver be the only owner, the shared OnboardingDecorationFitCorrector no longer writes the anchor itself. That reset moved into BrandDesignUpdateWelcomePage#onDecorationHidden, setting the same three properties on the same view, so the legacy renderer behaves as before.
Undecorated screens reserve a band to maintain height Legacy decided per screen. ADD_TO_DOCK held on to the outgoing bottom wing's anchor so the card stayed put, even though the wing itself was hidden, and INPUT_SCREEN_PREVIEW pinned it to the parent bottom. Both assumed a fixed step order. Embellishment.None now declares its own independent placement, backed by a Space, so the card keeps a comparable height on every undecorated screen. No anchoring rule reads which screen rendered before it, which matters because step order is meant to be reshuffled freely.

Example of undecorated card's position on a tablet:

  • On the left is the legacy behavior, which ADD_TO_DOCK and INPUT_SCREEN_PREVIEW had to work around.
  • On the right, the new behavior for undecorated cards that maintains height similar to a decorated card, which makes transitions smoother.
side_by_side

This can be tested now by applying this diff:

diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt
index 0685e14a4d..080b0f734c 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt
@@ -36,8 +36,8 @@ class DialogConfigResolver @Inject constructor() {
 
         is NewUserOnboardingActivityDialog.AddressBarPosition -> DialogConfig(
             background = OnboardingBackgroundStep.AddressBar,
-            embellishment = Embellishment.BobbingDax,
-            cardArrow = CardArrowConfig.AtEnd,
+            embellishment = Embellishment.None,
+            cardArrow = CardArrowConfig.Hidden,
             content = ContentConfig.AddressBar(
                 title = TextConfig.Resource(R.string.preOnboardingAddressBarTitle),
                 initialPosition = OmnibarType.SINGLE_TOP,

but will be easier to exercise when rest of the steps are migrated.

Steps to test this PR

flag off

  • Clean install.
  • Go through onboarding, verify all steps are handled.

transitions and skips

  • Apply below patch:
diff --git a/app/src/main/java/com/duckduckgo/app/onboardingbranddesignupdate/OnboardingBrandDesignUpdateToggles.kt b/app/src/main/java/com/duckduckgo/app/onboardingbranddesignupdate/OnboardingBrandDesignUpdateToggles.kt
index b6a1e4d352..41a8d85ad4 100644
--- a/app/src/main/java/com/duckduckgo/app/onboardingbranddesignupdate/OnboardingBrandDesignUpdateToggles.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboardingbranddesignupdate/OnboardingBrandDesignUpdateToggles.kt
@@ -58,6 +58,6 @@ interface OnboardingBrandDesignUpdateToggles {
     /**
      * Selects the config-driven renderer for the brand-design onboarding dialogs.
      */
-    @Toggle.DefaultValue(DefaultFeatureValue.FALSE)
+    @Toggle.DefaultValue(DefaultFeatureValue.TRUE)
     fun configDrivenDialogs(): Toggle
 }
  • Clean install.
  • Verify the permissions prompt, browser comparison chart, and address bar position steps execute.
  • Verify animations:
    • Fade transitions.
    • Comparison table staggered animation.
    • Step indicator transition.
    • On tablet: once any transition finishes, the card stays in its final position, doesn't snap to new anchor
  • Tap the card mid-animation. Content snaps into place.
  • Rotate on each step. Content appears complete with no animation, card correctly sized.
  • Rotate on each step while animating. Content appears complete with no animation, card correctly sized.
  • Background and re-enter mid-step. No stale animations, content snaps into place.
  • Select bottom address bar position and click "next". Verify the settings is applied.

fit calculation

  • Use a phone in portrait orientation.
  • Pause on the browser comparison screen.
  • Keep increasing the font until the wing barely doesn't fit and hides.
  • Continue to the next step and verify that the card transitions and bobbing Dax re-appears smoothly.

arrow slide

  • Apply this diff:
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt
index 0685e14a4d..eedd2d785d 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt
@@ -67,7 +67,7 @@ class DialogConfigResolver @Inject constructor() {
     private fun comparisonChart(chart: ComparisonChartConfig) = DialogConfig(
         background = OnboardingBackgroundStep.ComparisonChart,
         embellishment = Embellishment.BottomWing,
-        cardArrow = CardArrowConfig.AtEnd,
+        cardArrow = CardArrowConfig.AtStart,
         content = ContentConfig.ComparisonChart(title = TextConfig.Resource(chart.titleRes), config = chart),
         primaryCta = CtaConfig(
             text = TextConfig.Resource(chart.primaryCtaTextRes),
  • Verify that the arrow slides when transitioning between browser comparison and address bar position steps.

Note

Medium Risk
Large new onboarding UI stack on a feature flag; first-run UX and animation/fit behavior differ from legacy in several documented ways until all steps are migrated.

Overview
Adds a config-driven onboarding path gated by configDrivenDialogs() (default off). When brand design update and that toggle are on, onboarding uses ConfigDrivenWelcomePageFragment and buildConfigDrivenPageBlueprints() instead of the legacy brand-design welcome page.

Each step is described as value-comparable DialogConfig; ConfigDrivenOnboardingPageViewModel maps orchestrator steps via DialogConfigResolver, and DialogRenderEngine diffs configs and drives collaborators (background, embellishments, card anchor/arrow/stage, step indicator, content binders). Comparison chart and address bar position are ported end-to-end; other dialogs auto-advance with existing side effects until follow-ups land.

Layout/fit: OnboardingDecorationFitCorrector no longer repins the card when a decoration hides—hosts handle that (BrandDesignUpdateWelcomePage, config-driven embellishment path). An undecoratedBand Space plus Embellishment.None placement keeps undecorated screens at a stable card height. CardAnchorResolver centralizes card anchoring and arrow depth.

Reviewed by Cursor Bugbot for commit b4d0f59. Bugbot is set up for automated code reviews on this repo. Configure here.

afterFade is typed as an Animator factory, so entrance work that is not an
Animator has to be wrapped in a zero-duration ValueAnimator purely to receive
a start callback. That wrapper is only correct by way of a platform detail:
end() on a never-started ValueAnimator fires onAnimationStart while cancel()
does not, so a skip triggers the work and teardown suppresses it. Right
semantics, but inherited rather than written, and unpinnable by a unit test
that cannot inflate views.

onContentReady states them as engine behaviour instead. It runs at the same
point as afterFade, exactly once per render, on the animated, snapped and
skipped paths alike, and never once the handle is unbound. The two slots are
two ownership models at one moment: afterFade is bounded and engine-owned,
onContentReady is unbounded and stopped by the binder in unbind.

afterFade's KDoc also now records that the card stops intercepting touches as
it starts, so a screen revealing interactive content there has to gate its own
clickability until the animator ends.

FakeBackgroundController gains the release() override its interface has
required since that member was added. The test class did not compile without
it.
The fit corrector re-anchored the card itself on its veto path, hardcoding
verticalBias to 0f. CardAnchorController applies 0.5f on tablet for the same
unanchored case, so the two disagreed and whichever ran last won.

The corrector now reports through onDecorationHidden and leaves the card's
constraints alone. Config-driven re-runs cardAnchor.apply(null), which also
drops the arrow depth the callback used to set on its own. Legacy keeps the
old write verbatim in its callback, so its behaviour is unchanged.
Embellishment.None had no placement data of its own, so it fell through to a
hardcoded bias in CardAnchorController that also served the fit-veto case and
was wrong for it: legacy pins the card high when a decoration does not fit, but
centres it on a tablet when the screen has none.

None now maps to an undrawn Space that reserves the room a decoration would have
taken, floored at the card's bottom inset since the card anchors above the band
and so never reserves that inset itself. A decoration-less card therefore lands
near a decorated one without consulting whichever screen ran before it, which is
what the dock step reached for by keeping the outgoing wing INVISIBLE. That trick
made placement depend on step order, so the bottom wing's exit goes back to GONE.

Placement moves into an exhaustive table and bias selection into a pure resolver,
both now unit-testable. The table also means a new embellishment cannot compile
until its placement is stated, and the arrow depth reads the band's drawsArtwork
rather than inferring artwork from a non-null decoration.
@LukasPaczos

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment on lines -119 to -123
dialog.updateLayoutParams<ConstraintLayout.LayoutParams> {
verticalBias = 0f
bottomToTop = ConstraintLayout.LayoutParams.UNSET
bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only change to the "flag off" code path. This code is moved directly to BrandDesignUpdateWelcomePage, so no behavior change.

Comment on lines +549 to +556
onDecorationHidden = {
binding.daxDialogCta.cardView.setArrowDepthFraction(0f)
binding.daxDialogCta.root.updateLayoutParams<ConstraintLayout.LayoutParams> {
verticalBias = 0f
bottomToTop = ConstraintLayout.LayoutParams.UNSET
bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
}
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refs https://github.com/duckduckgo/Android/pull/9365/changes#r3690849481, this is the only change to production codebase, just a moved functional call.

@LukasPaczos

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 722e873. Configure here.

@LukasPaczos
LukasPaczos marked this pull request as ready for review July 31, 2026 14:10
@mikescamell

mikescamell commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@LukasPaczos Overall looking good! Added some comments by me and from a Claude review (indicated by 🤖 prefix)

  1. I noticed we get a white screen flash when rotating on either the browser comparison or address bar dialogs. Samsung S25
Screen_recording_20260803_120440.mp4
  1. The browser comparison background seems to jump up on the y axis now before it transitions out and down. Tablet emulator
Screen_recording_20260803_121110.mp4
  1. This is live as well, but Bobbing Dax seems to be sitting too high in landscape phone on my Samsung S25, could we add it as a follow up to fix after this refactor is complete? Should be an easy fix.
image

Comment thread app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt Outdated
@LukasPaczos

Copy link
Copy Markdown
Contributor Author
  1. I've seen this one before, but was never able to consistently reproduce. Can't now either:
Screen_recording_20260803_184729.mp4

Is that something you can reliably trigger?

  1. Fixed in b4d0f59.

  2. Tracking in https://app.asana.com/1/137249556945/project/1202552961248957/task/1217145974942449.

This is ready for another round!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants