Skip to content

Commit f3c5944

Browse files
committed
feat(card-browser): no interactive elements within rounded corners
After edge-to-edge, some interactive elements were hard to use as my Google Pixel 9 Pro had rounded corners * Corner handling is applied, so touchable controls at the bottom of the page are not obscured by the corner radius * Special-case: in landscape mode, the fast scroller does not hug the side if 3-button nav is implemented, so the corner detection is relaxed/removed depending on the corner radius Asserted in CardBrowserInsetsTest; captured in a landscape gesture-navigation screenshot test. Assisted-by: Claude Fable 5
1 parent 7e605ff commit f3c5944

5 files changed

Lines changed: 198 additions & 12 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ import com.ichi2.anki.ui.RecyclerFastScroller
134134
import com.ichi2.anki.ui.attachFastScroller
135135
import com.ichi2.anki.ui.internationalization.sentenceCase
136136
import com.ichi2.anki.undoAndShowSnackbar
137+
import com.ichi2.anki.utils.bottomCornerClearance
137138
import com.ichi2.anki.utils.ext.addPrepareMenuProvider
138139
import com.ichi2.anki.utils.ext.getParcelableCompat
139140
import com.ichi2.anki.utils.ext.hasCheckedBackground
@@ -436,9 +437,9 @@ class CardBrowserFragment :
436437
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
437438
)
438439
v.updatePadding(left = bars.left, right = bars.right)
439-
// The bottom of the safe area is above the navigation bar.
440+
// The bottom of the safe area is above the navigation bar and rounded display corners.
440441
// When scrolled to the bottom of the scrollbar should be aligned with the last row.
441-
val safeAreaBottom = bars.bottom
442+
val safeAreaBottom = maxOf(bars.bottom, insets.bottomCornerClearance(v))
442443
// Due to clipToPadding=false, only the last row is affected
443444
cardsListView.updatePadding(bottom = safeAreaBottom)
444445
// The scrollbar track stays full-height (edge to edge); only the handle is kept above

AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ class RecyclerFastScroller
9494
/**
9595
* Inset, in pixels, reserved at the bottom of the handle's travel.
9696
*
97-
* For the navigation bar with edge to edge support.
97+
* For rounded display corners with edge to edge support.
9898
*
9999
* The handle is constrained to `height - handleBottomInset`, so it stays touchable and its
100100
* bottom aligns with the list's last item at full scroll.
@@ -428,7 +428,7 @@ class RecyclerFastScroller
428428
val verticalScrollExtent = recyclerView!!.computeVerticalScrollExtent()
429429

430430
// The track (bar) spans the full height, but the handle travels only the area above
431-
// handleBottomInset so it stays clear of the navigation bar.
431+
// handleBottomInset so it stays clear of the navigation bar / rounded corner.
432432
val fullBarHeight = bar.height
433433
val trackHeight = (fullBarHeight - handleBottomInset).coerceAtLeast(0)
434434
val maxScrollOffset = (verticalScrollRange - verticalScrollExtent).coerceAtLeast(1)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
package com.ichi2.anki.utils
4+
5+
import android.view.View
6+
import androidx.core.view.RoundedCornerCompat
7+
import androidx.core.view.WindowInsetsCompat
8+
9+
/**
10+
* The radius, in pixels, of the larger of the two bottom corner radii.
11+
*/
12+
val WindowInsetsCompat.bottomRoundedCornerRadius: Int
13+
get() =
14+
maxOf(
15+
getRoundedCorner(RoundedCornerCompat.POSITION_BOTTOM_LEFT)?.radius ?: 0,
16+
getRoundedCorner(RoundedCornerCompat.POSITION_BOTTOM_RIGHT)?.radius ?: 0,
17+
)
18+
19+
/**
20+
* The vertical clearance, in pixels, which keeps end-aligned content clear of the
21+
* bottom rounded display corners.
22+
*
23+
* This takes insets into account: for example in landscape, the 3-button nav bar means very little
24+
* if no clearance from the corner is needed, as all content is shifted left.
25+
*
26+
* @param view supplies the layout direction: the end-side inset is the left system-bar inset
27+
* in RTL, otherwise the right
28+
*/
29+
fun WindowInsetsCompat.bottomCornerClearance(view: View): Int {
30+
val bars = getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
31+
val endInset = if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) bars.left else bars.right
32+
// The subtraction over-approximates the arc: content `d` inboard only needs
33+
// `r - sqrt(r² - (r - d)²)` of vertical clearance, and `r - d` is never less (a chord vs the
34+
// arc), so the content never dips into the corner.
35+
return (bottomRoundedCornerRadius - endInset).coerceAtLeast(0)
36+
}

AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserInsetsTest.kt

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package com.ichi2.anki
44

55
import android.view.View
66
import android.view.View.MeasureSpec
7+
import androidx.core.view.RoundedCornerCompat
78
import androidx.core.view.ViewCompat
89
import androidx.core.view.WindowInsetsCompat
910
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
@@ -28,8 +29,8 @@ import org.junit.runner.RunWith
2829
*
2930
* The list draws edge-to-edge under the navigation bar and the fast
3031
* scroller's track is full-height/edge-to-edge. Only the handle is held inside the safe area
31-
* (clearing the navigation bar), so at full scroll the handle's bottom lines up with the last
32-
* card's resting position.
32+
* (clearing the navigation bar and rounded display corners), so at full scroll the handle's bottom
33+
* lines up with the last card's resting position.
3334
*/
3435
@RunWith(AndroidJUnit4::class)
3536
class CardBrowserInsetsTest : RobolectricTest() {
@@ -145,6 +146,25 @@ class CardBrowserInsetsTest : RobolectricTest() {
145146
assertThat("the handle's bottom rests on the same line", handle.bottom, equalTo(restingLine))
146147
}
147148

149+
@Test
150+
fun `rounded display corners are cleared when larger than the navigation bar`() =
151+
withCardBrowser(noteCount = 1) { browser ->
152+
val cornerRadius = 48.dp.toPx(targetContext)
153+
browser.dispatchInsets(navBarBottom = 24.dp, bottomCornerRadius = 48.dp)
154+
browser.layoutForTest()
155+
156+
assertThat(
157+
"the last card rests above the rounded corners",
158+
browser.cardList.restingContentBottomToParent,
159+
equalTo(cornerRadius),
160+
)
161+
assertThat(
162+
"the handle is inset to the last card's resting line",
163+
browser.fastScroller.handleBottomInset,
164+
equalTo(cornerRadius),
165+
)
166+
}
167+
148168
@Test
149169
fun `toolbar is padded past a side navigation bar and cutout`() =
150170
withCardBrowser(noteCount = 1) { browser ->
@@ -166,6 +186,52 @@ class CardBrowserInsetsTest : RobolectricTest() {
166186
)
167187
}
168188

189+
@Test
190+
fun `a side navigation bar clearing the corner removes the bottom buffer`() =
191+
withCardBrowser(noteCount = 50) { browser ->
192+
// landscape with 3-button navigation: the bar is wider than the corner radius, so the
193+
// scroll handle is already inboard of the corner arc and no vertical buffer is needed
194+
browser.dispatchInsets(navBarRight = 48.dp, bottomCornerRadius = 34.dp)
195+
browser.layoutForTest()
196+
197+
val fragmentRoot =
198+
browser.supportFragmentManager
199+
.findFragmentById(R.id.card_browser_frame)!!
200+
.requireView()
201+
assertThat(
202+
"content is padded past the side navigation bar",
203+
fragmentRoot.paddingRight,
204+
equalTo(48.dp.toPx(targetContext)),
205+
)
206+
assertThat("no bottom buffer is reserved", browser.cardList.paddingBottom, equalTo(0))
207+
assertThat("the handle may reach the parent's bottom", browser.fastScroller.handleBottomInset, equalTo(0))
208+
209+
browser.cardList.scrollToPosition(49)
210+
browser.layoutForTest()
211+
assertThat("list is fully scrolled", browser.cardList.canScrollVertically(1), equalTo(false))
212+
assertThat(
213+
"the track reaches the parent's bottom when fully scrolled",
214+
browser.fastScroller.bar.bottom,
215+
equalTo(browser.fastScroller.height),
216+
)
217+
}
218+
219+
@Test
220+
fun `a partial side inset reduces the corner clearance by its width`() =
221+
withCardBrowser(noteCount = 1) { browser ->
222+
// a side inset narrower than the corner radius leaves the scroll handle inside the
223+
// corner's horizontal extent, but part-way in: only the remainder of the radius is
224+
// reserved (48dp radius - 24dp side inset)
225+
browser.dispatchInsets(navBarRight = 24.dp, bottomCornerRadius = 48.dp)
226+
browser.layoutForTest()
227+
228+
assertThat(
229+
"the clearance is the corner radius less the side inset",
230+
browser.fastScroller.handleBottomInset,
231+
equalTo(24.dp.toPx(targetContext)),
232+
)
233+
}
234+
169235
/** The gap, in pixels, between the bottom of this view and the bottom of its parent. */
170236
private val View.distanceToParentBottom: Int
171237
get() = (parent as View).height - bottom
@@ -188,6 +254,7 @@ class CardBrowserInsetsTest : RobolectricTest() {
188254
navBarBottom: Dp = 0.dp,
189255
navBarRight: Dp = 0.dp,
190256
cutoutLeft: Dp = 0.dp,
257+
bottomCornerRadius: Dp = 0.dp,
191258
) {
192259
val insets =
193260
with(targetContext) {
@@ -196,7 +263,16 @@ class CardBrowserInsetsTest : RobolectricTest() {
196263
.setInsets(statusBars(), insetsOf(top = 24.dp))
197264
.setInsets(navigationBars(), insetsOf(right = navBarRight, bottom = navBarBottom))
198265
.setInsets(displayCutout(), insetsOf(left = cutoutLeft))
199-
.build()
266+
.apply {
267+
val radius = bottomCornerRadius.toPx(targetContext)
268+
if (radius > 0) {
269+
// only the radius is read by the implementation; the center is unused
270+
setRoundedCorner(
271+
RoundedCornerCompat.POSITION_BOTTOM_LEFT,
272+
RoundedCornerCompat(RoundedCornerCompat.POSITION_BOTTOM_LEFT, radius, radius, radius),
273+
)
274+
}
275+
}.build()
200276
}
201277
ViewCompat.dispatchApplyWindowInsets(window.decorView, insets)
202278
}

AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.view.Gravity
66
import android.view.View
77
import android.view.ViewGroup
88
import android.widget.FrameLayout
9+
import androidx.core.view.RoundedCornerCompat
910
import androidx.core.view.ViewCompat
1011
import androidx.core.view.WindowInsetsCompat
1112
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
@@ -33,7 +34,7 @@ class CardBrowserScreenshotTest : ScreenshotTest() {
3334

3435
/**
3536
* When fully scrolled: the last row, the bottom of the scroll track and the bottom of the
36-
* scroll handle all rest on the same line, above the navigation bar.
37+
* scroll handle all rest on the same line, above the navigation bar/rounded corners.
3738
*
3839
* Screenshot counterpart of [CardBrowserInsetsTest]
3940
* `handle, track and last row rest on the same line when fully scrolled`, which asserts the
@@ -59,8 +60,8 @@ class CardBrowserScreenshotTest : ScreenshotTest() {
5960
/**
6061
* Landscape with 3-button navigation: the navigation bar is a side inset and the camera
6162
* cutout is on the opposite side. The toolbar and content clear both sides, no bottom buffer
62-
* is reserved (the navigation bar is a side inset) and the scroll track runs to the bottom
63-
* edge. [CardBrowserInsetsTest] asserts the same geometry programmatically.
63+
* is reserved (the side inset already clears the rounded corner) and the scroll track runs to
64+
* the bottom edge. [CardBrowserInsetsTest] asserts the same geometry programmatically.
6465
*/
6566
@Test
6667
fun cardBrowserLandscapeScrolledToBottom() {
@@ -81,6 +82,32 @@ class CardBrowserScreenshotTest : ScreenshotTest() {
8182
}
8283
}
8384

85+
/**
86+
* Landscape with gesture navigation: no side inset, so when scrolled to the bottom, the
87+
* final row and scrollbar should rest above the corner, to allow interaction.
88+
*
89+
* Screenshot counterpart of [CardBrowserInsetsTest]
90+
* `rounded display corners are cleared when larger than the navigation bar`.
91+
*/
92+
@Test
93+
fun cardBrowserLandscapeGestureNavigationScrolledToBottom() {
94+
RuntimeEnvironment.setQualifiers("+land")
95+
withCardBrowser(noteCount = 50) { browser ->
96+
browser.simulateGestureNavigationBar()
97+
98+
val list = browser.findViewById<RecyclerView>(R.id.card_browser_list)
99+
list.scrollToPosition(49)
100+
while (list.canScrollVertically(1)) list.scrollBy(0, 50)
101+
advanceRobolectricLooper()
102+
103+
// keep the auto-hiding fast scroller visible for the capture
104+
browser.findViewById<RecyclerFastScroller>(R.id.browser_scroller).show(animate = false)
105+
advanceRobolectricLooper()
106+
107+
captureScreen("landscape_gesture_scrolled_to_bottom")
108+
}
109+
}
110+
84111
/**
85112
* Robolectric reports zero system-bar insets by default. Inject realistic ones so the app's
86113
* edge-to-edge layout responds as it would on a real device, and overlay a translucent band
@@ -113,9 +140,48 @@ class CardBrowserScreenshotTest : ScreenshotTest() {
113140
)
114141
}
115142

143+
/**
144+
* As [simulateNavigationBar], but for landscape with gesture navigation: a short bottom
145+
* inset, with rounded display corners larger than it.
146+
*/
147+
private fun CardBrowser.simulateGestureNavigationBar() {
148+
val navBarHeight = 24.dp
149+
val insets =
150+
with(targetContext) {
151+
WindowInsetsCompat
152+
.Builder()
153+
.setInsets(statusBars(), insetsOf(top = 24.dp))
154+
.setInsets(navigationBars(), insetsOf(bottom = navBarHeight))
155+
.apply {
156+
val radius = 34.dp.toPx(targetContext)
157+
// only the radius is read by the implementation; the center is unused
158+
setRoundedCorner(
159+
RoundedCornerCompat.POSITION_BOTTOM_LEFT,
160+
RoundedCornerCompat(RoundedCornerCompat.POSITION_BOTTOM_LEFT, radius, radius, radius),
161+
)
162+
}.build()
163+
}
164+
ViewCompat.dispatchApplyWindowInsets(window.decorView, insets)
165+
166+
val decor = window.decorView as ViewGroup
167+
val navBarOverlay =
168+
View(this).apply {
169+
setBackgroundColor(0x80000000.toInt())
170+
}
171+
decor.addView(
172+
navBarOverlay,
173+
FrameLayout.LayoutParams(
174+
FrameLayout.LayoutParams.MATCH_PARENT,
175+
navBarHeight.toPx(targetContext),
176+
Gravity.BOTTOM,
177+
),
178+
)
179+
}
180+
116181
/**
117182
* As [simulateNavigationBar], but for landscape with 3-button navigation: the navigation bar
118-
* is a side inset, with the camera cutout on the opposite side.
183+
* is a side inset (wider than the rounded corner it clears), with the camera cutout on the
184+
* opposite side.
119185
*/
120186
private fun CardBrowser.simulateSideNavigationBar() {
121187
val navBarWidth = 48.dp
@@ -126,7 +192,14 @@ class CardBrowserScreenshotTest : ScreenshotTest() {
126192
.setInsets(statusBars(), insetsOf(top = 24.dp))
127193
.setInsets(navigationBars(), insetsOf(right = navBarWidth))
128194
.setInsets(displayCutout(), insetsOf(left = 32.dp))
129-
.build()
195+
.apply {
196+
val radius = 34.dp.toPx(targetContext)
197+
// only the radius is read by the implementation; the center is unused
198+
setRoundedCorner(
199+
RoundedCornerCompat.POSITION_BOTTOM_LEFT,
200+
RoundedCornerCompat(RoundedCornerCompat.POSITION_BOTTOM_LEFT, radius, radius, radius),
201+
)
202+
}.build()
130203
}
131204
ViewCompat.dispatchApplyWindowInsets(window.decorView, insets)
132205

0 commit comments

Comments
 (0)