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. --> + - + + +