From e0a8a01e988e8ae544cf00be3fc65ee54eab2297 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 22:16:05 +0000 Subject: [PATCH 1/2] Scroll dash profile title off-screen with the app grid The per-profile dash title (Applications / profile name) sat in a row above the app grid and stayed pinned there even as the grid scrolled, permanently occupying screen space. Overlay the title on the grid's top padding instead (clipToPadding=false so the padding scrolls away with the apps) and translate the title in step with the grid's scroll, so it slides off-screen together with the first row of apps. --- AGENTS.md | 9 +++- .../desktop/dash/ProfilePagerAdapter.kt | 51 +++++++++++++++++++ .../res/layout/widget_dash_profile_page.xml | 36 +++++++------ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b244f368..363730cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,14 @@ etc/ — design assets (SVG/XCF sources, (the genie) and `ThemeApplier` reach the live grid (both null-safe for when it isn't laid out yet). The per-page `LayoutTransition` is set in the adapter (it can't be in `LayoutTransitionConfigurer`, which runs before any page - exists). Tests force the lazy layout via `ActivityTestSupport.layoutDashApps`. + exists). Each page's title (`tvDashHomeTitle` — the profile name, or + "Applications" for a single profile) overlays the top of its grid rather + than sitting in a row above it: the grid reserves a top padding the height + of the title (`clipToPadding=false` so that padding scrolls away with the + apps) and `ProfilePagerAdapter.bindTitleCollapse` translates the title in + step with the grid's scroll, so the title scrolls off-screen with the first + row instead of permanently occupying the top of the dash. + Tests force the lazy layout via `ActivityTestSupport.layoutDashApps`. Grid sizing is owned by `desktop/dash/DashGrid` (the dash counterpart to `widgets/WidgetGrid`): the user picks a column count across the short screen edge (`DASH_GRID_COLUMNS`, adaptive default from `smallestScreenWidthDp`), diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/dash/ProfilePagerAdapter.kt b/app/src/main/java/be/robinj/distrohopper/desktop/dash/ProfilePagerAdapter.kt index e3ded643..40b75b75 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/dash/ProfilePagerAdapter.kt +++ b/app/src/main/java/be/robinj/distrohopper/desktop/dash/ProfilePagerAdapter.kt @@ -5,6 +5,7 @@ import android.os.UserHandle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.AbsListView import android.widget.GridView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView @@ -51,9 +52,59 @@ class ProfilePagerAdapter( setStartDelay(LayoutTransition.APPEARING, 0) } + this.bindTitleCollapse(holder) + return holder } + /** + * Makes the page title scroll off-screen together with the apps instead of + * staying pinned above them. The title overlays the grid's top padding (see + * widget_dash_profile_page); we keep that padding the height of the title and + * translate the title in step with the grid's scroll, so it slides up out of + * view as the first row of apps does. + */ + private fun bindTitleCollapse(holder: PageViewHolder) { + // Reserve room at the top of the grid for the overlaid title, refreshed + // whenever the title's height changes (text/theme/rotation). clipToPadding + // is false (see layout) so this padding scrolls away with the apps. + holder.title.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ -> + val height = bottom - top + if (holder.grid.paddingTop != height) { + holder.grid.setPadding(holder.grid.paddingLeft, height, + holder.grid.paddingRight, holder.grid.paddingBottom) + } + this.updateTitleOffset(holder) + } + + holder.grid.setOnScrollListener(object : AbsListView.OnScrollListener { + override fun onScrollStateChanged(view: AbsListView, scrollState: Int) {} + override fun onScroll(view: AbsListView, firstVisibleItem: Int, + visibleItemCount: Int, totalItemCount: Int) { + this@ProfilePagerAdapter.updateTitleOffset(holder) + } + }) + } + + private fun updateTitleOffset(holder: PageViewHolder) { + val collapse = holder.title.height + if (collapse == 0) { + holder.title.translationY = 0F + return + } + + // At rest the first row sits at paddingTop (== title height); as the grid + // scrolls up that gap shrinks. Once the first row has scrolled past the + // top the title is fully gone, so clamp to the title's height. + val first = holder.grid.getChildAt(0) + val scrolled = if (holder.grid.firstVisiblePosition == 0 && first != null) { + holder.grid.paddingTop - first.top + } else { + collapse + } + holder.title.translationY = -scrolled.coerceIn(0, collapse).toFloat() + } + override fun getItemCount(): Int = this.profiles.size override fun onBindViewHolder(holder: PageViewHolder, position: Int) { diff --git a/app/src/main/res/layout/widget_dash_profile_page.xml b/app/src/main/res/layout/widget_dash_profile_page.xml index 933a0765..6cbee62d 100644 --- a/app/src/main/res/layout/widget_dash_profile_page.xml +++ b/app/src/main/res/layout/widget_dash_profile_page.xml @@ -4,28 +4,34 @@ The grid carries the gvDashHomeApps id so the genie animation, layout transition and icon-width theming resolve the current page (only the current page is attached when the pager is idle). The title swipes with the - page: the profile name, or "Applications" when there is a single profile. --> - + page: the profile name, or "Applications" when there is a single profile. - + The title overlays the top of the grid rather than sitting in a row above + it: the grid keeps a top padding the height of the title (clipToPadding is + false so that padding scrolls away with the content), and + ProfilePagerAdapter translates the title in step with the grid's scroll. So + the title scrolls off-screen together with the apps instead of permanently + occupying the top of the dash. --> + - + + + From eba7fa272b46fbdc5823849e7ed0d21badcea741 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 23:45:38 +0000 Subject: [PATCH 2/2] Fix dash title collapse: rotation crash and pre-collapsed swiped pages Problem 1: ViewPager2 crashes mid-scroll (e.g. on rotation) when a page's child ViewGroup has a LayoutTransition that animates the parent hierarchy. Add setAnimateParentHierarchy(false) to the grid's LayoutTransition. Problem 2: the title rested already scrolled off-screen on swiped-to pages. The collapse offset was derived from the first row's rest top, but that was inconsistent: AbsListView does not re-anchor already-filled rows when paddingTop changes at runtime, so applying the reserve-padding after the fill (via the title's layout listener) left the first row at top=0 and the title computed as fully scrolled. Reserve the padding in onBindViewHolder before the grid's adapter is set, from a one-off measured title height (a single line, identical for every profile), so the first fill of every page lands the first row at paddingTop. Reset the title baseline on bind and recompute the at-rest offset from a grid layout-change listener (the scroll listener never fires for a page whose apps fit without scrolling). --- AGENTS.md | 25 +++++-- .../desktop/dash/ProfilePagerAdapter.kt | 75 ++++++++++++++----- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 363730cf..a6c437f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,13 +93,24 @@ etc/ — design assets (SVG/XCF sources, (the genie) and `ThemeApplier` reach the live grid (both null-safe for when it isn't laid out yet). The per-page `LayoutTransition` is set in the adapter (it can't be in `LayoutTransitionConfigurer`, which runs before any page - exists). Each page's title (`tvDashHomeTitle` — the profile name, or - "Applications" for a single profile) overlays the top of its grid rather - than sitting in a row above it: the grid reserves a top padding the height - of the title (`clipToPadding=false` so that padding scrolls away with the - apps) and `ProfilePagerAdapter.bindTitleCollapse` translates the title in - step with the grid's scroll, so the title scrolls off-screen with the first - row instead of permanently occupying the top of the dash. + exists) and **must** keep `setAnimateParentHierarchy(false)`: ViewPager2 + rejects a page whose child ViewGroup has a parent-animating LayoutTransition + and crashes mid-scroll (notably on rotation). Each page's title + (`tvDashHomeTitle` — the profile name, or "Applications" for a single + profile) overlays the top of its grid rather than sitting in a row above it: + the grid reserves a top padding the height of the title (`clipToPadding=false` + so that padding scrolls away with the apps) and + `ProfilePagerAdapter.updateTitleOffset` translates the title up in step with + the grid's scroll, so it scrolls off-screen with the first row instead of + permanently occupying the top of the dash. The padding is applied in + `onBindViewHolder` *before* the grid's adapter is set (from a one-off + `measureTitleHeight`, stable since the title is one line of identical height + for every profile): AbsListView does not re-anchor already-filled rows when + paddingTop changes at runtime, so applying it after the fill left the first + row at `top=0` and the title resting pre-collapsed on swiped-to pages. A grid + layout-change listener recomputes the at-rest offset after any (re)fill or + rotation, since the scroll listener never fires for a page whose apps fit + without scrolling. Tests force the lazy layout via `ActivityTestSupport.layoutDashApps`. Grid sizing is owned by `desktop/dash/DashGrid` (the dash counterpart to `widgets/WidgetGrid`): the user picks a column count across the short screen diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/dash/ProfilePagerAdapter.kt b/app/src/main/java/be/robinj/distrohopper/desktop/dash/ProfilePagerAdapter.kt index 40b75b75..fe81bac4 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/dash/ProfilePagerAdapter.kt +++ b/app/src/main/java/be/robinj/distrohopper/desktop/dash/ProfilePagerAdapter.kt @@ -46,10 +46,15 @@ class ProfilePagerAdapter( // The icon appear/disappear transition that LayoutTransitionConfigurer // used to set on the standalone grid; set per page here since the pages - // don't exist when that configurer runs. + // don't exist when that configurer runs. setAnimateParentHierarchy(false) + // is required: ViewPager2 rejects a page whose child ViewGroup has a + // LayoutTransition that animates the parent hierarchy (the default), and + // crashes mid-scroll on the next layout pass (e.g. on rotation) with + // "...interferes with the scrolling animation". holder.grid.layoutTransition = LayoutTransition().apply { setDuration(180L) setStartDelay(LayoutTransition.APPEARING, 0) + setAnimateParentHierarchy(false) } this.bindTitleCollapse(holder) @@ -60,20 +65,24 @@ class ProfilePagerAdapter( /** * Makes the page title scroll off-screen together with the apps instead of * staying pinned above them. The title overlays the grid's top padding (see - * widget_dash_profile_page); we keep that padding the height of the title and - * translate the title in step with the grid's scroll, so it slides up out of - * view as the first row of apps does. + * widget_dash_profile_page) and we translate it up in step with the grid's + * scroll, so it slides out of view as the first row of apps does. + * + * The padding that reserves the title's space is set in onBindViewHolder + * *before* the grid's adapter (so the first fill lands the first row at + * paddingTop); it must not change after the grid is populated, because + * AbsListView does not re-anchor already-laid-out rows when paddingTop + * changes at runtime (that was the cause of the title resting pre-collapsed + * on swiped-to pages). Here we only attach the listeners that read the + * scroll position and move the title: + * - a scroll listener for live scrolling, and + * - a layout-change listener so the at-rest baseline is recomputed after + * any (re)fill or rotation — the scroll listener never fires for a page + * whose apps fit without scrolling. Both only set translationY, which is + * a draw-time transform, so they don't trigger further layout. */ private fun bindTitleCollapse(holder: PageViewHolder) { - // Reserve room at the top of the grid for the overlaid title, refreshed - // whenever the title's height changes (text/theme/rotation). clipToPadding - // is false (see layout) so this padding scrolls away with the apps. - holder.title.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ -> - val height = bottom - top - if (holder.grid.paddingTop != height) { - holder.grid.setPadding(holder.grid.paddingLeft, height, - holder.grid.paddingRight, holder.grid.paddingBottom) - } + holder.grid.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> this.updateTitleOffset(holder) } @@ -87,18 +96,20 @@ class ProfilePagerAdapter( } private fun updateTitleOffset(holder: PageViewHolder) { - val collapse = holder.title.height + // The reserved space (== title height) is the grid's top padding; the + // title is fully off-screen once scrolled by that much. + val collapse = holder.grid.paddingTop if (collapse == 0) { holder.title.translationY = 0F return } - // At rest the first row sits at paddingTop (== title height); as the grid - // scrolls up that gap shrinks. Once the first row has scrolled past the - // top the title is fully gone, so clamp to the title's height. + // With the padding applied before the fill, the first row rests at + // paddingTop; as the grid scrolls up that gap shrinks. Once the first + // row has scrolled past the top the title is fully gone. val first = holder.grid.getChildAt(0) val scrolled = if (holder.grid.firstVisiblePosition == 0 && first != null) { - holder.grid.paddingTop - first.top + collapse - first.top } else { collapse } @@ -121,9 +132,37 @@ class ProfilePagerAdapter( holder.title.setShadowLayer(5F, 2F, 2F, res.getColor(theme.dash_applauncher_text_shadow_colour)) + // Reserve the title's space as grid top padding *before* assigning the + // adapter, so the very first fill of this page lands the first row at + // paddingTop. Changing padding after the grid is populated does not move + // already-laid-out rows, which left the title pre-collapsed on swiped-to + // pages. The title is a single line of identical height for every + // profile, so a one-off measure is stable across pages. + val titleHeight = this.measureTitleHeight(holder.title) + if (holder.grid.paddingTop != titleHeight) { + holder.grid.setPadding(holder.grid.paddingLeft, titleHeight, + holder.grid.paddingRight, holder.grid.paddingBottom) + } + holder.grid.adapter = this.gridAdapters[position] holder.grid.onItemClickListener = AppLauncherClickListener(this.activity) holder.grid.onItemLongClickListener = AppLauncherLongClickListener(this.activity) + + // Fresh bind starts at scroll 0; reset the baseline so a recycled holder + // doesn't carry a previous (scrolled) page's translation. The layout + // listener corrects it once this page's rows are laid out. + holder.title.translationY = 0F + } + + /** + * The laid-out height of the single-line title, measured without needing the + * view to be attached/laid out yet, so it can be reserved as grid padding + * before the first fill. + */ + private fun measureTitleHeight(title: TextView): Int { + val unspecified = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + title.measure(unspecified, unspecified) + return title.measuredHeight } override fun onViewAttachedToWindow(holder: PageViewHolder) {