diff --git a/AGENTS.md b/AGENTS.md
index b244f368..a6c437f4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,7 +93,25 @@ 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) 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
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..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
@@ -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
@@ -45,15 +46,76 @@ 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)
+
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) 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) {
+ holder.grid.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ 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) {
+ // 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
+ }
+
+ // 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) {
+ collapse - 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) {
@@ -70,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) {
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. -->
+
-
+
+
+