From eb05a5e5cd415f436f745b9f586dfd6acd2fa7e3 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 16 Oct 2026 12:11:25 +0100 Subject: [PATCH] feat(android): large navbar titles that collapse like iOS `large: true` now works on Android. The navbar grows by 52dp (same as iOS 44 to 96) and shows a bold 32sp title in its own row under the toolbar. Sending `large: false` with the same title collapses it to the inline toolbar title with a 220ms animation: the bar shrinks, the large title fades out and the inline title fades in. A new title snaps, since the page transition already animates. Insets follow the target state so the page moves once, not on every frame. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../NativeNavigationPlugin.java | 104 +++++++++++++++++- .../NavbarLargeTitleSupport.java | 48 ++++++++ .../NavbarLargeTitleSupportTest.java | 37 +++++++ src/definitions.ts | 4 +- 5 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 android/src/main/java/app/capgo/nativenavigation/NavbarLargeTitleSupport.java create mode 100644 android/src/test/java/app/capgo/nativenavigation/NavbarLargeTitleSupportTest.java diff --git a/README.md b/README.md index 59fc3db..d2604f9 100644 --- a/README.md +++ b/README.md @@ -737,7 +737,7 @@ Native navbar state. | **`hidden`** | boolean | Hide the native navbar. | | **`title`** | string | Main title. | | **`subtitle`** | string | Secondary title where supported by the platform. | -| **`large`** | boolean | Prefer a large iOS title style. | +| **`large`** | boolean | Show a large title under the bar, like iOS large titles. On Android the bar grows by 52dp and the title moves into its own row. Send `large: false` with the same title to collapse it to the inline title (animated on Android), e.g. once the page scrolls. | | **`transparent`** | boolean | Prefer transparent/scroll-edge style. | | **`blurEffect`** | NativeNavigationBlurEffect | iOS blur/material effect for the navbar background when glass is not available. Defaults to `systemChromeMaterial` for transparent bars. | | **`glass`** | NativeNavigationGlassOptions | Optional glass background behavior. Overrides `configure({ glass })` for this navbar update. | diff --git a/android/src/main/java/app/capgo/nativenavigation/NativeNavigationPlugin.java b/android/src/main/java/app/capgo/nativenavigation/NativeNavigationPlugin.java index 8ab1a18..e902a74 100644 --- a/android/src/main/java/app/capgo/nativenavigation/NativeNavigationPlugin.java +++ b/android/src/main/java/app/capgo/nativenavigation/NativeNavigationPlugin.java @@ -21,6 +21,7 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.os.Build; +import android.text.TextUtils; import android.util.TypedValue; import android.view.Gravity; import android.view.HapticFeedbackConstants; @@ -97,6 +98,12 @@ public class NativeNavigationPlugin extends Plugin { private NavbarScrollSupport.ScrollBehavior navbarScrollBehavior = NavbarScrollSupport.ScrollBehavior.NONE; private float navbarScrollThreshold = 8f; private boolean navbarScrollCollapsed = false; + private TextView largeTitleView; + private boolean navbarLarge = false; + private float navbarLargeProgress = 0f; + private ValueAnimator navbarLargeAnimator; + private String navbarTitle; + private int navbarForegroundColor = Color.rgb(20, 24, 32); private boolean tabbarVisible = false; private String contentInsetMode = "css"; private GlassOptions defaultGlassOptions = GlassOptions.defaults(); @@ -212,7 +219,19 @@ public void setNavbar(PluginCall call) { } Toolbar nativeToolbar = ensureToolbar(); - nativeToolbar.setTitle(call.getString("title", "")); + String title = call.getString("title", ""); + boolean large = Boolean.TRUE.equals(call.getBoolean("large", false)); + boolean animateLarge = NavbarLargeTitleSupport.shouldAnimate( + navbarContainer.getVisibility() == View.VISIBLE, + navbarTitle, + title, + navbarLarge, + large + ); + navbarTitle = title; + navbarLarge = large; + nativeToolbar.setTitle(title); + largeTitleView.setText(title); nativeToolbar.setSubtitle(call.getString("subtitle", null)); nativeToolbar.getMenu().clear(); menuActionIds.clear(); @@ -235,6 +254,7 @@ public void setNavbar(PluginCall call) { navbarGlassConfig = call.getObject("glass", null); navbarGlassOptions = GlassOptions.from(navbarGlassConfig, defaultGlassOptions); applyToolbarColors(nativeToolbar, colors); + setNavbarLargeProgress(large ? 1f : 0f, animateLarge); navbarContainer.setVisibility(View.VISIBLE); configureNavbarScrollBehavior(call); layoutChrome(); @@ -775,8 +795,20 @@ private Toolbar ensureToolbar() { return true; }); + largeTitleView = new TextView(getContext()); + largeTitleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, NavbarLargeTitleSupport.LARGE_TITLE_TEXT_SP); + largeTitleView.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + largeTitleView.setSingleLine(true); + largeTitleView.setEllipsize(TextUtils.TruncateAt.END); + largeTitleView.setGravity(Gravity.BOTTOM | Gravity.START); + largeTitleView.setPaddingRelative(dp(16), 0, dp(16), dp(NavbarLargeTitleSupport.LARGE_TITLE_BOTTOM_PADDING_DP)); + largeTitleView.setAlpha(0f); + // The toolbar keeps the title for screen readers; the large copy is visual only. + largeTitleView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + navbarContainer.addView(navbarGlassSurface); navbarContainer.addView(toolbar); + navbarContainer.addView(largeTitleView); if (root != null) { root.addView(navbarContainer); } else { @@ -2219,7 +2251,11 @@ private void applyToolbarColors(Toolbar nativeToolbar, JSObject colors) { navbarBackgroundColor = background; int foreground = parseColor(colors.getString("foreground", null), foregroundFallback); tintColor = tint; + navbarForegroundColor = foreground; nativeToolbar.setTitleTextColor(foreground); + if (largeTitleView != null) { + largeTitleView.setTextColor(foreground); + } nativeToolbar.setSubtitleTextColor(withAlpha(foreground, 190)); Drawable navigationIcon = nativeToolbar.getNavigationIcon(); if (navigationIcon != null) { @@ -2499,7 +2535,7 @@ private void layoutChrome() { } int status = statusBarInset(); int bottom = navigationBarInset(); - int navbarToolbar = navbarScrollCollapsed ? 0 : dp(DEFAULT_NAVBAR_DP); + int navbarToolbar = navbarScrollCollapsed ? 0 : navbarBarHeight(navbarLargeProgress); int navbarHeight = navbarVisible ? status + navbarToolbar : 0; boolean tabbarLayoutVisible = tabbarVisible || tabbarChromeHideAnimating; @@ -2517,6 +2553,13 @@ private void layoutChrome() { ); toolbarParams.topMargin = status; toolbar.setLayoutParams(toolbarParams); + FrameLayout.LayoutParams largeTitleParams = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(NavbarLargeTitleSupport.LARGE_TITLE_ROW_DP), + Gravity.TOP + ); + largeTitleParams.topMargin = status + dp(DEFAULT_NAVBAR_DP); + largeTitleView.setLayoutParams(largeTitleParams); fillContainer(navbarGlassSurface); } @@ -2847,11 +2890,63 @@ private void setNavbarScrollCollapsed(boolean collapsed, boolean animated) { } } + /** Navbar height under the status bar for a large title progress, in device pixels. */ + private int navbarBarHeight(float largeProgress) { + return Math.round(dp((double) NavbarLargeTitleSupport.barHeightDp(largeProgress))); + } + + private void setNavbarLargeProgress(float target, boolean animated) { + if (navbarLargeAnimator != null) { + navbarLargeAnimator.cancel(); + navbarLargeAnimator = null; + } + if (!animated || navbarLargeProgress == target) { + navbarLargeProgress = target; + applyNavbarLargeProgress(); + return; + } + ValueAnimator animator = ValueAnimator.ofFloat(navbarLargeProgress, target); + animator.setDuration(NavbarLargeTitleSupport.ANIMATION_MS); + animator.setInterpolator(new PathInterpolator(0.2f, 0f, 0f, 1f)); + animator.addUpdateListener((animation) -> { + navbarLargeProgress = (float) animation.getAnimatedValue(); + applyNavbarLargeProgress(); + resizeNavbarContainer(); + }); + navbarLargeAnimator = animator; + animator.start(); + } + + /** Animation frames only resize the navbar; the tabbar and insets keep their layout. */ + private void resizeNavbarContainer() { + if (navbarContainer == null || !navbarVisible) { + return; + } + ViewGroup.LayoutParams params = navbarContainer.getLayoutParams(); + if (params == null) { + return; + } + params.height = statusBarInset() + (navbarScrollCollapsed ? 0 : navbarBarHeight(navbarLargeProgress)); + navbarContainer.setLayoutParams(params); + } + + private void applyNavbarLargeProgress() { + if (toolbar == null || largeTitleView == null) { + return; + } + float progress = navbarLargeProgress; + largeTitleView.setAlpha(NavbarLargeTitleSupport.largeTitleAlpha(progress)); + largeTitleView.setTranslationY(-(1f - progress) * dp(NavbarLargeTitleSupport.LARGE_TITLE_ROW_DP) * 0.5f); + largeTitleView.setVisibility(progress > 0f ? View.VISIBLE : View.INVISIBLE); + float inlineAlpha = NavbarLargeTitleSupport.inlineTitleAlpha(progress); + toolbar.setTitleTextColor(withAlpha(navbarForegroundColor, Math.round(Color.alpha(navbarForegroundColor) * inlineAlpha))); + } + private void applyNavbarScrollCollapsedAppearance(boolean animated) { if (navbarContainer == null) { return; } - float hideDistance = dp(DEFAULT_NAVBAR_DP); + float hideDistance = navbarBarHeight(navbarLargeProgress); if (animated) { navbarContainer .animate() @@ -2868,7 +2963,8 @@ private void applyNavbarScrollCollapsedAppearance(boolean animated) { private JSObject currentInsets() { // View layout uses device pixels; CSS variables must be density-independent // or a 3x screen writes `192px` which CSS treats as 192 CSS px. - int navbarToolbar = navbarScrollCollapsed ? 0 : dp(DEFAULT_NAVBAR_DP); + // Insets follow the target title state so the page moves once, not on every animation frame. + int navbarToolbar = navbarScrollCollapsed ? 0 : navbarBarHeight(navbarLarge ? 1f : 0f); int top = navbarVisible ? statusBarInset() + navbarToolbar : 0; int bottom = tabbarVisible ? navigationBarInset() + dp(tabbarStyle.totalHeight()) + dp(tabbarStyle.bottomGap) : 0; JSObject insets = new JSObject(); diff --git a/android/src/main/java/app/capgo/nativenavigation/NavbarLargeTitleSupport.java b/android/src/main/java/app/capgo/nativenavigation/NavbarLargeTitleSupport.java new file mode 100644 index 0000000..94bf4c9 --- /dev/null +++ b/android/src/main/java/app/capgo/nativenavigation/NavbarLargeTitleSupport.java @@ -0,0 +1,48 @@ +package app.capgo.nativenavigation; + +/** + * Large title geometry for the Android navbar. Progress runs from 0 (inline title in the + * toolbar row) to 1 (large title in its own row under the toolbar), like an iOS large title. + */ +public final class NavbarLargeTitleSupport { + + /** Toolbar row height, matching the default Android navbar. */ + public static final int TOOLBAR_DP = 56; + /** Extra row for the large title. iOS adds 52pt (44 to 96); Android uses the same row. */ + public static final int LARGE_TITLE_ROW_DP = 52; + public static final int LARGE_TITLE_TEXT_SP = 32; + public static final int LARGE_TITLE_BOTTOM_PADDING_DP = 6; + public static final int ANIMATION_MS = 220; + + private NavbarLargeTitleSupport() {} + + /** Navbar height below the status bar, in dp, for a large title progress. */ + public static float barHeightDp(float progress) { + return TOOLBAR_DP + LARGE_TITLE_ROW_DP * clamp(progress); + } + + /** The large title fades out over the first half of the collapse so both titles never overlap. */ + public static float largeTitleAlpha(float progress) { + return clamp(progress * 2f - 1f); + } + + /** The inline toolbar title fades in over the second half of the collapse. */ + public static float inlineTitleAlpha(float progress) { + return clamp(1f - progress * 2f); + } + + /** + * Animate only a collapse or expand of the same visible header. A new title is a new page; + * the page transition already animates, so the header snaps to its new state. + */ + public static boolean shouldAnimate(boolean wasVisible, String previousTitle, String title, boolean wasLarge, boolean large) { + if (!wasVisible || wasLarge == large) { + return false; + } + return previousTitle == null ? title == null : previousTitle.equals(title); + } + + private static float clamp(float value) { + return Math.max(0f, Math.min(1f, value)); + } +} diff --git a/android/src/test/java/app/capgo/nativenavigation/NavbarLargeTitleSupportTest.java b/android/src/test/java/app/capgo/nativenavigation/NavbarLargeTitleSupportTest.java new file mode 100644 index 0000000..f7a9528 --- /dev/null +++ b/android/src/test/java/app/capgo/nativenavigation/NavbarLargeTitleSupportTest.java @@ -0,0 +1,37 @@ +package app.capgo.nativenavigation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class NavbarLargeTitleSupportTest { + + @Test + public void barHeightGrowsWithTheLargeTitleRow() { + assertEquals(56f, NavbarLargeTitleSupport.barHeightDp(0f), 0.001f); + assertEquals(108f, NavbarLargeTitleSupport.barHeightDp(1f), 0.001f); + assertEquals(82f, NavbarLargeTitleSupport.barHeightDp(0.5f), 0.001f); + assertEquals(108f, NavbarLargeTitleSupport.barHeightDp(3f), 0.001f); + } + + @Test + public void titlesNeverShowTogether() { + assertEquals(1f, NavbarLargeTitleSupport.largeTitleAlpha(1f), 0.001f); + assertEquals(0f, NavbarLargeTitleSupport.inlineTitleAlpha(1f), 0.001f); + assertEquals(0f, NavbarLargeTitleSupport.largeTitleAlpha(0.5f), 0.001f); + assertEquals(0f, NavbarLargeTitleSupport.inlineTitleAlpha(0.5f), 0.001f); + assertEquals(0f, NavbarLargeTitleSupport.largeTitleAlpha(0f), 0.001f); + assertEquals(1f, NavbarLargeTitleSupport.inlineTitleAlpha(0f), 0.001f); + } + + @Test + public void animatesOnlyCollapseOfTheSamePage() { + assertTrue(NavbarLargeTitleSupport.shouldAnimate(true, "Teams", "Teams", true, false)); + assertTrue(NavbarLargeTitleSupport.shouldAnimate(true, "Teams", "Teams", false, true)); + assertFalse(NavbarLargeTitleSupport.shouldAnimate(true, "Teams", "Rules", true, false)); + assertFalse(NavbarLargeTitleSupport.shouldAnimate(false, "Teams", "Teams", true, false)); + assertFalse(NavbarLargeTitleSupport.shouldAnimate(true, "Teams", "Teams", true, true)); + } +} diff --git a/src/definitions.ts b/src/definitions.ts index c2716b3..2558c78 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -340,7 +340,9 @@ export interface NativeNavigationNavbarOptions { subtitle?: string; /** - * Prefer a large iOS title style. + * Show a large title under the bar, like iOS large titles. On Android the bar grows by 52dp and + * the title moves into its own row. Send `large: false` with the same title to collapse it to the + * inline title (animated on Android), e.g. once the page scrolls. */ large?: boolean;