Skip to content

Commit f55f940

Browse files
committed
Clip checkout sheet content to rounded top corners
Assisted-By: devx/6db0ff39-6d19-4074-ab7f-a027dc8b067f
1 parent 90d60c5 commit f55f940

5 files changed

Lines changed: 148 additions & 28 deletions

File tree

platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheet.kt

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import androidx.activity.OnBackPressedCallback
2222
import androidx.annotation.ColorInt
2323
import androidx.appcompat.content.res.AppCompatResources
2424
import androidx.appcompat.widget.Toolbar
25+
import androidx.core.graphics.ColorUtils
2526
import androidx.core.graphics.drawable.DrawableCompat
2627
import androidx.core.graphics.drawable.toDrawable
2728
import androidx.core.view.ViewCompat
@@ -102,8 +103,9 @@ internal class CheckoutBottomSheet(
102103

103104
addWebViewToContainer(sheetColors.webViewBackgroundColor, checkoutWebView)
104105
show()
105-
// Dialog.show() can apply default window sizing after the initial configuration.
106+
// Dialog.show() can apply default window sizing and decor flags after the initial configuration.
106107
window?.setCheckoutBottomSheetWindowLayout()
108+
window?.setTransparentSystemBars(navigationBackgroundColor = sheetColors.webViewBackgroundColor)
107109
findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)?.animateIn()
108110
focusCloseButtonForAccessibility(activity)
109111
log.d(LOG_TAG, "Bottom sheet shown.")
@@ -120,11 +122,12 @@ internal class CheckoutBottomSheet(
120122
val headerBackgroundColor = colorScheme.headerBackgroundColor(isDarkTheme).getValue(activity)
121123
val headerFontColor = colorScheme.headerFontColor(isDarkTheme).getValue(activity)
122124
val webViewBackgroundColor = colorScheme.webViewBackgroundColor(isDarkTheme).getValue(activity)
125+
val cornerRadiusPx = sheetStyle.cornerRadiusDp.dpToPx(activity)
123126

124127
findViewById<Toolbar>(R.id.checkoutKitHeader)?.apply {
125128
log.d(LOG_TAG, "Applying configured header colors and inflating menu.")
126129
title = ""
127-
background = roundedTopCornerDrawable(activity, headerBackgroundColor, sheetStyle.cornerRadiusDp)
130+
background = roundedTopCornerDrawable(headerBackgroundColor, cornerRadiusPx)
128131
elevation = sheetStyle.toolbarElevationDp.dpToPx(activity)
129132
setTitleTextColor(headerFontColor)
130133
inflateMenu(R.menu.checkout_menu)
@@ -154,6 +157,8 @@ internal class CheckoutBottomSheet(
154157
}
155158
}
156159

160+
findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)?.topCornerRadiusPx = cornerRadiusPx
161+
157162
findViewById<RelativeLayout>(R.id.checkoutKitContainer)
158163
?.setBackgroundColor(webViewBackgroundColor)
159164

@@ -482,15 +487,28 @@ private fun Window.setCheckoutBottomSheetWindowLayout() {
482487
}
483488

484489
/**
485-
* Makes system bars transparent while retaining contrast enforcement for 3-button navigation controls.
490+
* Makes system bars transparent while retaining contrast for 3-button navigation controls.
486491
*/
487492
@Suppress("DEPRECATION")
488-
private fun Window.setTransparentSystemBars() {
493+
private fun Window.setTransparentSystemBars(@ColorInt navigationBackgroundColor: Int? = null) {
494+
val shouldUseDarkNavigationButtons = navigationBackgroundColor?.let { backgroundColor ->
495+
ColorUtils.calculateLuminance(backgroundColor) > LIGHT_COLOR_LUMINANCE_THRESHOLD
496+
} ?: false
497+
489498
statusBarColor = Color.TRANSPARENT
490-
navigationBarColor = Color.TRANSPARENT
499+
navigationBarColor = if (shouldUseDarkNavigationButtons && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
500+
LEGACY_LIGHT_BACKGROUND_NAVIGATION_BAR_COLOR
501+
} else {
502+
Color.TRANSPARENT
503+
}
504+
if (navigationBackgroundColor != null) {
505+
WindowCompat.getInsetsController(this, decorView).isAppearanceLightNavigationBars = shouldUseDarkNavigationButtons
506+
}
491507
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
492508
isNavigationBarContrastEnforced = true
493509
}
494510
}
495511

496512
private const val LOG_TAG = "CheckoutBottomSheet"
513+
private const val LIGHT_COLOR_LUMINANCE_THRESHOLD = 0.5
514+
private const val LEGACY_LIGHT_BACKGROUND_NAVIGATION_BAR_COLOR = 0x52000000

platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBottomSheetLayout.kt

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.shopify.checkoutkit
22

33
import android.content.Context
4+
import android.graphics.Canvas
5+
import android.graphics.Path
46
import android.graphics.Rect
7+
import android.graphics.RectF
58
import android.util.AttributeSet
69
import android.view.MotionEvent
710
import android.view.VelocityTracker
@@ -10,6 +13,7 @@ import android.view.ViewConfiguration
1013
import android.view.ViewTreeObserver
1114
import android.view.animation.PathInterpolator
1215
import android.widget.RelativeLayout
16+
import androidx.core.graphics.withClip
1317
import kotlin.math.abs
1418
import kotlin.math.roundToInt
1519

@@ -29,6 +33,8 @@ internal class CheckoutBottomSheetLayout @JvmOverloads constructor(
2933
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
3034
private val minimumFlingVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity
3135
private val childHitRect = Rect()
36+
private val sheetClipPath = Path()
37+
private val sheetClipBounds = RectF()
3238
private val openInterpolator = OpenInterpolator.create()
3339
private val closeInterpolator = CloseInterpolator.create()
3440
private val settleInterpolator = SettleInterpolator.create()
@@ -46,6 +52,18 @@ internal class CheckoutBottomSheetLayout @JvmOverloads constructor(
4652
private var dismissAnimationRunning = false
4753
private var dismissAnimationEndAction: (() -> Unit)? = null
4854

55+
/**
56+
* Radius used to clip only the visible top corners of the sheet.
57+
*/
58+
var topCornerRadiusPx = 0f
59+
set(value) {
60+
val coercedValue = value.coerceAtLeast(0f)
61+
if (field == coercedValue) return
62+
63+
field = coercedValue
64+
invalidate()
65+
}
66+
4967
/**
5068
* Registers the content view whose downward scroll should be consumed before the sheet can drag.
5169
*/
@@ -282,6 +300,36 @@ internal class CheckoutBottomSheetLayout @JvmOverloads constructor(
282300
return super.performClick()
283301
}
284302

303+
/**
304+
* Clips child drawing to the configured top corner radius to avoid square child surfaces leaking at the sheet edge.
305+
*/
306+
override fun draw(canvas: Canvas) {
307+
if (topCornerRadiusPx <= 0f || width <= 0 || height <= 0) {
308+
super.draw(canvas)
309+
return
310+
}
311+
312+
sheetClipBounds.set(0f, 0f, width.toFloat(), height.toFloat())
313+
sheetClipPath.reset()
314+
sheetClipPath.addRoundRect(
315+
sheetClipBounds,
316+
floatArrayOf(
317+
topCornerRadiusPx,
318+
topCornerRadiusPx,
319+
topCornerRadiusPx,
320+
topCornerRadiusPx,
321+
0f,
322+
0f,
323+
0f,
324+
0f,
325+
),
326+
Path.Direction.CW,
327+
)
328+
canvas.withClip(sheetClipPath) {
329+
super.draw(this)
330+
}
331+
}
332+
285333
/**
286334
* Releases gesture and pre-draw resources when the dialog window is torn down.
287335
*/
@@ -346,11 +394,7 @@ internal class CheckoutBottomSheetLayout @JvmOverloads constructor(
346394
private fun settleExpanded() {
347395
renderedDragOffsetY = materializeTopOffsetAsTranslation(renderedDragOffsetY)
348396
dragOffsetY = 0f
349-
animate()
350-
.translationY(0f)
351-
.setDuration(SETTLE_ANIMATION_DURATION_MS)
352-
.setInterpolator(settleInterpolator)
353-
.start()
397+
animateExpanded(settleInterpolator, SETTLE_ANIMATION_DURATION_MS)
354398
}
355399

356400
/**
@@ -442,6 +486,20 @@ private fun View.materializeTopOffsetAsTranslation(currentOffsetY: Int): Int {
442486
private fun View.isReadyForHitTesting(): Boolean =
443487
width > 0 && height > 0 && parent != null
444488

489+
/**
490+
* Animates the sheet view back to its fully expanded position.
491+
*/
492+
private fun View.animateExpanded(
493+
interpolator: PathInterpolator,
494+
durationMs: Long,
495+
) {
496+
animate()
497+
.translationY(0f)
498+
.setDuration(durationMs)
499+
.setInterpolator(interpolator)
500+
.start()
501+
}
502+
445503
/**
446504
* Removes a pending opening pre-draw listener if the view tree observer is still valid.
447505
*/

platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutSheetHeaderBackgroundDrawable.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@ import androidx.annotation.ColorInt
99
* Creates the sheet header background with rounded top corners and square bottom corners.
1010
*/
1111
internal fun roundedTopCornerDrawable(
12-
context: Context,
1312
@ColorInt color: Int,
14-
cornerRadiusDp: Float,
13+
cornerRadiusPx: Float,
1514
): GradientDrawable {
16-
val cornerRadius = cornerRadiusDp.dpToPx(context)
17-
return CheckoutSheetHeaderBackgroundDrawable(color, cornerRadius)
15+
return CheckoutSheetHeaderBackgroundDrawable(color, cornerRadiusPx)
1816
}
1917

2018
internal fun Float.dpToPx(context: Context): Float =

platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetStyleTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import android.graphics.drawable.ColorDrawable
44
import android.graphics.drawable.GradientDrawable
55
import android.view.MotionEvent
66
import android.view.View
7+
import android.view.Window
78
import android.view.WindowManager
89
import android.widget.RelativeLayout
910
import android.widget.TextView
1011
import androidx.activity.ComponentActivity
1112
import androidx.appcompat.widget.Toolbar
13+
import androidx.core.view.WindowCompat
1214
import org.assertj.core.api.Assertions.assertThat
1315
import org.assertj.core.api.Assertions.fail
1416
import org.junit.After
@@ -19,6 +21,7 @@ import org.mockito.kotlin.mock
1921
import org.mockito.kotlin.verify
2022
import org.robolectric.Robolectric
2123
import org.robolectric.RobolectricTestRunner
24+
import org.robolectric.annotation.Config
2225
import org.robolectric.shadows.ShadowLooper
2326

2427
@RunWith(RobolectricTestRunner::class)
@@ -72,6 +75,56 @@ class CheckoutBottomSheetStyleTest {
7275
assertThat(windowDimFlag).isEqualTo(0)
7376
}
7477

78+
@Suppress("DEPRECATION")
79+
@Config(sdk = [29])
80+
@Test
81+
fun `keeps transparent system bars with navigation contrast enforcement`() {
82+
val sheet = presentBottomSheet()
83+
84+
assertThat(sheet.window!!.statusBarColor).isEqualTo(android.graphics.Color.TRANSPARENT)
85+
assertThat(sheet.window!!.navigationBarColor).isEqualTo(android.graphics.Color.TRANSPARENT)
86+
assertThat(sheet.window!!.isNavigationBarContrastEnforced).isTrue()
87+
}
88+
89+
@Suppress("DEPRECATION")
90+
@Config(sdk = [29])
91+
@Test
92+
fun `uses dark navigation bar buttons over light checkout backgrounds`() {
93+
ShopifyCheckoutKit.configure {
94+
it.colorScheme = ColorScheme.Light()
95+
}
96+
97+
val sheet = presentBottomSheet()
98+
99+
assertThat(sheet.window!!.isAppearanceLightNavigationBars).isTrue()
100+
assertThat(sheet.window!!.navigationBarColor).isEqualTo(android.graphics.Color.TRANSPARENT)
101+
}
102+
103+
@Config(sdk = [29])
104+
@Test
105+
fun `uses light navigation bar buttons over dark checkout backgrounds`() {
106+
ShopifyCheckoutKit.configure {
107+
it.colorScheme = ColorScheme.Dark()
108+
}
109+
110+
val sheet = presentBottomSheet()
111+
112+
assertThat(sheet.window!!.isAppearanceLightNavigationBars).isFalse()
113+
}
114+
115+
@Suppress("DEPRECATION")
116+
@Config(sdk = [25])
117+
@Test
118+
fun `uses navigation bar scrim over light checkout backgrounds when dark nav buttons are unavailable`() {
119+
ShopifyCheckoutKit.configure {
120+
it.colorScheme = ColorScheme.Light()
121+
}
122+
123+
val sheet = presentBottomSheet()
124+
125+
assertThat(sheet.window!!.navigationBarColor).isEqualTo(LEGACY_LIGHT_BACKGROUND_NAVIGATION_BAR_COLOR)
126+
}
127+
75128
@Test
76129
fun `disabling drag to dismiss keeps outside touch cancellation enabled`() {
77130
val listener = mock<DefaultCheckoutListener>()
@@ -201,9 +254,13 @@ class CheckoutBottomSheetStyleTest {
201254
private fun motionEvent(action: Int, y: Float): MotionEvent =
202255
MotionEvent.obtain(0, 0, action, 100f, y, 0)
203256

257+
private val Window.isAppearanceLightNavigationBars: Boolean
258+
get() = WindowCompat.getInsetsController(this, decorView).isAppearanceLightNavigationBars
259+
204260
private companion object {
205261
private const val TEST_SHEET_SIZE = 1000
206262
private const val TEST_SHEET_HEADER_SIZE = 120
207263
private const val MATERIAL_COMPONENTS_SCRIM_COLOR = 0x52000000
264+
private const val LEGACY_LIGHT_BACKGROUND_NAVIGATION_BAR_COLOR = 0x52000000
208265
}
209266
}

platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBottomSheetTest.kt

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package com.shopify.checkoutkit
22

33
import android.content.Context
4-
import android.graphics.Color as AndroidColor
54
import android.graphics.drawable.ColorDrawable
6-
import android.os.Build
75
import android.os.Looper
86
import android.view.Gravity
97
import android.view.MotionEvent
@@ -383,17 +381,6 @@ class CheckoutBottomSheetTest {
383381
assertThat(activity.window.attributes.softInputMode).isEqualTo(originalMode)
384382
}
385383

386-
@Test
387-
fun `bottom sheet keeps transparent system bars with navigation contrast`() {
388-
val sheet = presentBottomSheet()
389-
390-
assertThat(sheet.window?.statusBarColor).isEqualTo(AndroidColor.TRANSPARENT)
391-
assertThat(sheet.window?.navigationBarColor).isEqualTo(AndroidColor.TRANSPARENT)
392-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
393-
assertThat(sheet.window?.isNavigationBarContrastEnforced).isTrue()
394-
}
395-
}
396-
397384
@Test
398385
fun `present returns handle allowing dismissal of checkout`() {
399386
val checkout = ShopifyCheckoutKit.present("https://shopify.com", activity, processor)
@@ -505,15 +492,17 @@ class CheckoutBottomSheetTest {
505492
}
506493

507494
@Test
508-
fun `rounds only top header corners`() {
495+
fun `rounds and clips only top sheet corners`() {
509496
ShopifyCheckoutKit.configuration.sheetStyle = CheckoutSheetStyle(cornerRadiusDp = 18f)
510497

511498
val sheet = presentBottomSheet()
512499

500+
val bottomSheet = sheet.findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)!!
513501
val header = sheet.findViewById<Toolbar>(R.id.checkoutKitHeader)!!
514502
val background = header.background as CheckoutSheetHeaderBackgroundDrawable
515503
val cornerRadius = 18f.dpToPx(activity)
516504

505+
assertThat(bottomSheet.topCornerRadiusPx).isEqualTo(cornerRadius)
517506
assertThat(background.appliedCornerRadii).containsExactly(
518507
cornerRadius,
519508
cornerRadius,

0 commit comments

Comments
 (0)