diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt index 173445bca..57fcb733d 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt @@ -7,9 +7,10 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.graphics.Color import android.os.Build import android.view.MenuItem +import android.view.View import android.view.View.INVISIBLE import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.view.Window import android.view.WindowManager import android.widget.ProgressBar import android.widget.RelativeLayout @@ -17,10 +18,14 @@ import androidx.activity.ComponentActivity import androidx.activity.ComponentDialog import androidx.activity.OnBackPressedCallback import androidx.annotation.ColorInt +import androidx.annotation.RequiresApi import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.Toolbar import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.children import com.shopify.checkoutkit.ShopifyCheckoutKit.log @@ -32,6 +37,7 @@ internal class CheckoutDialog( ) : ComponentDialog(context) { private var presentedCheckoutWebView: CheckoutWebView? = null + private lateinit var keyboardInsets: CheckoutDialogKeyboardInsets private val backNavigationCallback = object : OnBackPressedCallback(enabled = true) { override fun handleOnBackPressed() { @@ -46,15 +52,9 @@ internal class CheckoutDialog( fun start(context: ComponentActivity) { log.d(LOG_TAG, "Dialog start called.") + window?.configureForCheckoutDialog() setContentView(R.layout.dialog_checkout) - window?.setLayout(MATCH_PARENT, WRAP_CONTENT) - window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) - // Although this flag is deprecated in newest targets, it's properly - // addressing the keyboard focus on the WebView within the dialog. - // The non-deprecated alternative (insets listener) does notify about - // keyboard insets when visible, but it is not adjusting the pan - // properly into the fields. To be investigated further. - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + keyboardInsets = CheckoutDialogKeyboardInsets(findViewById(R.id.checkoutKitRoot)) log.d(LOG_TAG, "Finding or creating WebView.") val checkoutWebView = CheckoutWebView.checkoutViewFor(checkoutUrl, context) @@ -100,6 +100,13 @@ internal class CheckoutDialog( log.d(LOG_TAG, "Showing dialog.") show() + // Dialog window size is only applied reliably after show(). + window?.setLayout(MATCH_PARENT, MATCH_PARENT) + keyboardInsets.requestApplyInsets() + } + + internal fun applyKeyboardInset(imeBottom: Int) { + keyboardInsets.applyKeyboardInset(imeBottom) } private fun MenuItem.setupCloseButton(colorScheme: ColorScheme) { @@ -140,7 +147,7 @@ internal class CheckoutDialog( findViewById(R.id.checkoutKitContainer).apply { log.d(LOG_TAG, "Found parent view, setting its colors and layout params.") setBackgroundColor(colorScheme.webViewBackgroundColor()) - val layoutParams = RelativeLayout.LayoutParams(WRAP_CONTENT, MATCH_PARENT) + val layoutParams = RelativeLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) layoutParams.addRule(RelativeLayout.BELOW, R.id.progressBar) checkoutWebView.removeFromParent() log.d(LOG_TAG, "Adding WebView to parent view.") @@ -200,3 +207,80 @@ internal class CheckoutDialog( private const val LOG_TAG = "CheckoutDialog" } } + +internal fun Window.configureForCheckoutDialog() { + setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + setLayout(MATCH_PARENT, MATCH_PARENT) + WindowCompat.setDecorFitsSystemWindows(this, false) + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + clearFitInsets() + } +} + +@RequiresApi(Build.VERSION_CODES.R) +private fun Window.clearFitInsets() { + attributes = attributes.apply { + setFitInsetsTypes(0) + setFitInsetsSides(0) + } +} + +internal class CheckoutDialogKeyboardInsets( + private val root: View, +) { + private val rootPaddingTop = root.paddingTop + private val rootPaddingBottom = root.paddingBottom + + init { + ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets -> + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val bottomInset = if (imeBottom > 0) imeBottom else systemBars.bottom + applyInsets( + top = unappliedTopInset(systemBars.top), + bottom = unappliedBottomInset(bottomInset), + ) + insets + } + } + + fun requestApplyInsets() { + ViewCompat.requestApplyInsets(root) + } + + fun applyKeyboardInset(imeBottom: Int) { + applyInsets(top = 0, bottom = imeBottom.coerceAtLeast(0)) + } + + private fun applyInsets(top: Int, bottom: Int) { + root.setPadding( + root.paddingLeft, + rootPaddingTop + top.coerceAtLeast(0), + root.paddingRight, + rootPaddingBottom + bottom.coerceAtLeast(0), + ) + } + + private fun unappliedTopInset(inset: Int): Int { + if (inset <= 0 || root.height == 0) return inset + return if (root.locationOnScreenY() >= inset - INSET_APPLIED_TOLERANCE_PX) 0 else inset + } + + private fun unappliedBottomInset(inset: Int): Int { + if (inset <= 0 || root.height == 0) return inset + val rootBottom = root.locationOnScreenY() + root.height + val insetTop = root.resources.displayMetrics.heightPixels - inset + return if (rootBottom <= insetTop + INSET_APPLIED_TOLERANCE_PX) 0 else inset + } + + private fun View.locationOnScreenY(): Int { + val location = IntArray(2) + getLocationOnScreen(location) + return location[1] + } + + private companion object { + private const val INSET_APPLIED_TOLERANCE_PX = 2 + } +} diff --git a/platforms/android/lib/src/main/res/layout/dialog_checkout.xml b/platforms/android/lib/src/main/res/layout/dialog_checkout.xml index 8fca29d13..64376b7df 100644 --- a/platforms/android/lib/src/main/res/layout/dialog_checkout.xml +++ b/platforms/android/lib/src/main/res/layout/dialog_checkout.xml @@ -1,8 +1,10 @@ - + android:background="@android:color/transparent" + android:orientation="vertical"> - + diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt index 4eabd7acd..66b251b3e 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt @@ -4,7 +4,10 @@ import android.app.Dialog import android.graphics.drawable.ColorDrawable import android.os.Looper import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.WindowManager import android.webkit.WebView +import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.activity.ComponentActivity import androidx.appcompat.widget.Toolbar @@ -63,6 +66,24 @@ class CheckoutDialogTest { assertThat(dialog.isShowing).isTrue } + @Test + fun `dialog fills height and handles keyboard with insets`() { + ShopifyCheckoutKit.present("https://shopify.com", activity, processor) + + val dialog = ShadowDialog.getLatestDialog() + val attributes = dialog.window?.attributes + val root = dialog.findViewById(R.id.checkoutKitRoot) + val container = dialog.findViewById(R.id.checkoutKitContainer) + val containerLayoutParams = container.layoutParams as LinearLayout.LayoutParams + + assertThat(root).isInstanceOf(LinearLayout::class.java) + assertThat(attributes?.height).isEqualTo(MATCH_PARENT) + assertThat(attributes?.softInputMode?.and(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)) + .isEqualTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + assertThat(containerLayoutParams.height).isZero() + assertThat(containerLayoutParams.weight).isEqualTo(1f) + } + @Test fun `checkoutView is added to the container when dialog is presented`() { ShopifyCheckoutKit.present("https://shopify.com", activity, processor) @@ -83,8 +104,26 @@ class CheckoutDialogTest { val webView: WebView = ShadowDialog.getLatestDialog() .findViewById(R.id.checkoutKitContainer) .children.firstOrNull { it is WebView } as WebView? ?: fail("No WebVew found in dialog") + val layoutParams = webView.layoutParams as RelativeLayout.LayoutParams assertThat(shadowOf(webView).wasOnResumeCalled()).isTrue() + assertThat(layoutParams.width).isEqualTo(MATCH_PARENT) + assertThat(layoutParams.height).isEqualTo(MATCH_PARENT) + } + + @Test + fun `ime inset changes pad checkout content without resizing window`() { + ShopifyCheckoutKit.present("https://shopify.com", activity, processor) + val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog + val root = dialog.findViewById(R.id.checkoutKitRoot) + dialog.window?.setLayout(MATCH_PARENT, 400) + + assertThat(root.paddingBottom).isZero() + + dialog.applyKeyboardInset(100) + + assertThat(dialog.window?.attributes?.height).isEqualTo(400) + assertThat(root.paddingBottom).isEqualTo(100) } @Test @@ -238,7 +277,7 @@ class CheckoutDialogTest { } @Test - fun `sets WebView container background color based on current configuration`() { + fun `sets checkout content background color based on current configuration`() { val customColors = customColors() ShopifyCheckoutKit.configuration.colorScheme = ColorScheme.Web(customColors) diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/diff.txt b/platforms/android/samples/CheckoutKitAndroidDemo/diff.txt new file mode 100644 index 000000000..99b9b39e4 --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/diff.txt @@ -0,0 +1,272 @@ +diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt +index 173445bc..57fcb733 100644 +--- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt ++++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDialog.kt +@@ -7,9 +7,10 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES + import android.graphics.Color + import android.os.Build + import android.view.MenuItem ++import android.view.View + import android.view.View.INVISIBLE + import android.view.ViewGroup.LayoutParams.MATCH_PARENT +-import android.view.ViewGroup.LayoutParams.WRAP_CONTENT ++import android.view.Window + import android.view.WindowManager + import android.widget.ProgressBar + import android.widget.RelativeLayout +@@ -17,10 +18,14 @@ import androidx.activity.ComponentActivity + import androidx.activity.ComponentDialog + import androidx.activity.OnBackPressedCallback + import androidx.annotation.ColorInt ++import androidx.annotation.RequiresApi + import androidx.appcompat.content.res.AppCompatResources + import androidx.appcompat.widget.Toolbar + import androidx.core.graphics.drawable.DrawableCompat + import androidx.core.graphics.drawable.toDrawable ++import androidx.core.view.ViewCompat ++import androidx.core.view.WindowCompat ++import androidx.core.view.WindowInsetsCompat + import androidx.core.view.children + import com.shopify.checkoutkit.ShopifyCheckoutKit.log + +@@ -32,6 +37,7 @@ internal class CheckoutDialog( + ) : ComponentDialog(context) { + + private var presentedCheckoutWebView: CheckoutWebView? = null ++ private lateinit var keyboardInsets: CheckoutDialogKeyboardInsets + + private val backNavigationCallback = object : OnBackPressedCallback(enabled = true) { + override fun handleOnBackPressed() { +@@ -46,15 +52,9 @@ internal class CheckoutDialog( + + fun start(context: ComponentActivity) { + log.d(LOG_TAG, "Dialog start called.") ++ window?.configureForCheckoutDialog() + setContentView(R.layout.dialog_checkout) +- window?.setLayout(MATCH_PARENT, WRAP_CONTENT) +- window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) +- // Although this flag is deprecated in newest targets, it's properly +- // addressing the keyboard focus on the WebView within the dialog. +- // The non-deprecated alternative (insets listener) does notify about +- // keyboard insets when visible, but it is not adjusting the pan +- // properly into the fields. To be investigated further. +- window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) ++ keyboardInsets = CheckoutDialogKeyboardInsets(findViewById(R.id.checkoutKitRoot)) + + log.d(LOG_TAG, "Finding or creating WebView.") + val checkoutWebView = CheckoutWebView.checkoutViewFor(checkoutUrl, context) +@@ -100,6 +100,13 @@ internal class CheckoutDialog( + + log.d(LOG_TAG, "Showing dialog.") + show() ++ // Dialog window size is only applied reliably after show(). ++ window?.setLayout(MATCH_PARENT, MATCH_PARENT) ++ keyboardInsets.requestApplyInsets() ++ } ++ ++ internal fun applyKeyboardInset(imeBottom: Int) { ++ keyboardInsets.applyKeyboardInset(imeBottom) + } + + private fun MenuItem.setupCloseButton(colorScheme: ColorScheme) { +@@ -140,7 +147,7 @@ internal class CheckoutDialog( + findViewById(R.id.checkoutKitContainer).apply { + log.d(LOG_TAG, "Found parent view, setting its colors and layout params.") + setBackgroundColor(colorScheme.webViewBackgroundColor()) +- val layoutParams = RelativeLayout.LayoutParams(WRAP_CONTENT, MATCH_PARENT) ++ val layoutParams = RelativeLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + layoutParams.addRule(RelativeLayout.BELOW, R.id.progressBar) + checkoutWebView.removeFromParent() + log.d(LOG_TAG, "Adding WebView to parent view.") +@@ -200,3 +207,80 @@ internal class CheckoutDialog( + private const val LOG_TAG = "CheckoutDialog" + } + } ++ ++internal fun Window.configureForCheckoutDialog() { ++ setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) ++ setLayout(MATCH_PARENT, MATCH_PARENT) ++ WindowCompat.setDecorFitsSystemWindows(this, false) ++ setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ++ clearFitInsets() ++ } ++} ++ ++@RequiresApi(Build.VERSION_CODES.R) ++private fun Window.clearFitInsets() { ++ attributes = attributes.apply { ++ setFitInsetsTypes(0) ++ setFitInsetsSides(0) ++ } ++} ++ ++internal class CheckoutDialogKeyboardInsets( ++ private val root: View, ++) { ++ private val rootPaddingTop = root.paddingTop ++ private val rootPaddingBottom = root.paddingBottom ++ ++ init { ++ ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets -> ++ val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom ++ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) ++ val bottomInset = if (imeBottom > 0) imeBottom else systemBars.bottom ++ applyInsets( ++ top = unappliedTopInset(systemBars.top), ++ bottom = unappliedBottomInset(bottomInset), ++ ) ++ insets ++ } ++ } ++ ++ fun requestApplyInsets() { ++ ViewCompat.requestApplyInsets(root) ++ } ++ ++ fun applyKeyboardInset(imeBottom: Int) { ++ applyInsets(top = 0, bottom = imeBottom.coerceAtLeast(0)) ++ } ++ ++ private fun applyInsets(top: Int, bottom: Int) { ++ root.setPadding( ++ root.paddingLeft, ++ rootPaddingTop + top.coerceAtLeast(0), ++ root.paddingRight, ++ rootPaddingBottom + bottom.coerceAtLeast(0), ++ ) ++ } ++ ++ private fun unappliedTopInset(inset: Int): Int { ++ if (inset <= 0 || root.height == 0) return inset ++ return if (root.locationOnScreenY() >= inset - INSET_APPLIED_TOLERANCE_PX) 0 else inset ++ } ++ ++ private fun unappliedBottomInset(inset: Int): Int { ++ if (inset <= 0 || root.height == 0) return inset ++ val rootBottom = root.locationOnScreenY() + root.height ++ val insetTop = root.resources.displayMetrics.heightPixels - inset ++ return if (rootBottom <= insetTop + INSET_APPLIED_TOLERANCE_PX) 0 else inset ++ } ++ ++ private fun View.locationOnScreenY(): Int { ++ val location = IntArray(2) ++ getLocationOnScreen(location) ++ return location[1] ++ } ++ ++ private companion object { ++ private const val INSET_APPLIED_TOLERANCE_PX = 2 ++ } ++} +diff --git a/platforms/android/lib/src/main/res/layout/dialog_checkout.xml b/platforms/android/lib/src/main/res/layout/dialog_checkout.xml +index 8fca29d1..64376b7d 100644 +--- a/platforms/android/lib/src/main/res/layout/dialog_checkout.xml ++++ b/platforms/android/lib/src/main/res/layout/dialog_checkout.xml +@@ -1,8 +1,10 @@ +- ++ android:background="@android:color/transparent" ++ android:orientation="vertical"> + + + + + + +- ++ +diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt +index 4eabd7ac..66b251b3 100644 +--- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt ++++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDialogTest.kt +@@ -4,7 +4,10 @@ import android.app.Dialog + import android.graphics.drawable.ColorDrawable + import android.os.Looper + import android.view.View ++import android.view.ViewGroup.LayoutParams.MATCH_PARENT ++import android.view.WindowManager + import android.webkit.WebView ++import android.widget.LinearLayout + import android.widget.RelativeLayout + import androidx.activity.ComponentActivity + import androidx.appcompat.widget.Toolbar +@@ -63,6 +66,24 @@ class CheckoutDialogTest { + assertThat(dialog.isShowing).isTrue + } + ++ @Test ++ fun `dialog fills height and handles keyboard with insets`() { ++ ShopifyCheckoutKit.present("https://shopify.com", activity, processor) ++ ++ val dialog = ShadowDialog.getLatestDialog() ++ val attributes = dialog.window?.attributes ++ val root = dialog.findViewById(R.id.checkoutKitRoot) ++ val container = dialog.findViewById(R.id.checkoutKitContainer) ++ val containerLayoutParams = container.layoutParams as LinearLayout.LayoutParams ++ ++ assertThat(root).isInstanceOf(LinearLayout::class.java) ++ assertThat(attributes?.height).isEqualTo(MATCH_PARENT) ++ assertThat(attributes?.softInputMode?.and(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)) ++ .isEqualTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) ++ assertThat(containerLayoutParams.height).isZero() ++ assertThat(containerLayoutParams.weight).isEqualTo(1f) ++ } ++ + @Test + fun `checkoutView is added to the container when dialog is presented`() { + ShopifyCheckoutKit.present("https://shopify.com", activity, processor) +@@ -83,8 +104,26 @@ class CheckoutDialogTest { + val webView: WebView = ShadowDialog.getLatestDialog() + .findViewById(R.id.checkoutKitContainer) + .children.firstOrNull { it is WebView } as WebView? ?: fail("No WebVew found in dialog") ++ val layoutParams = webView.layoutParams as RelativeLayout.LayoutParams + + assertThat(shadowOf(webView).wasOnResumeCalled()).isTrue() ++ assertThat(layoutParams.width).isEqualTo(MATCH_PARENT) ++ assertThat(layoutParams.height).isEqualTo(MATCH_PARENT) ++ } ++ ++ @Test ++ fun `ime inset changes pad checkout content without resizing window`() { ++ ShopifyCheckoutKit.present("https://shopify.com", activity, processor) ++ val dialog = ShadowDialog.getLatestDialog() as CheckoutDialog ++ val root = dialog.findViewById(R.id.checkoutKitRoot) ++ dialog.window?.setLayout(MATCH_PARENT, 400) ++ ++ assertThat(root.paddingBottom).isZero() ++ ++ dialog.applyKeyboardInset(100) ++ ++ assertThat(dialog.window?.attributes?.height).isEqualTo(400) ++ assertThat(root.paddingBottom).isEqualTo(100) + } + + @Test +@@ -238,7 +277,7 @@ class CheckoutDialogTest { + } + + @Test +- fun `sets WebView container background color based on current configuration`() { ++ fun `sets checkout content background color based on current configuration`() { + val customColors = customColors() + ShopifyCheckoutKit.configuration.colorScheme = ColorScheme.Web(customColors) +