Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
36 changes: 21 additions & 15 deletions app/src/main/res/layout/widget_dash_profile_page.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
page: the profile name, or "Applications" when there is a single profile.

<TextView
android:id="@+id/tvDashHomeTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="@style/textshadow_2px_black"
android:textColor="@android:color/white" />
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. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<GridView
android:id="@+id/gvDashHomeApps"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:choiceMode="singleChoice"
android:clipToPadding="false"
android:columnWidth="@dimen/dash_applauncher_width"
android:gravity="center"
android:numColumns="auto_fit"
android:scrollbars="none" />
</LinearLayout>

<TextView
android:id="@+id/tvDashHomeTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="@style/textshadow_2px_black"
android:textColor="@android:color/white" />
</FrameLayout>
Loading