Skip to content

[link] Dynamic bottom sheet height #10838

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
May 30, 2025
Merged

[link] Dynamic bottom sheet height #10838

merged 25 commits into from
May 30, 2025

Conversation

lng-stripe
Copy link
Contributor

@lng-stripe lng-stripe commented May 20, 2025

Summary

Make the Link bottom sheet have dynamic height (adjusts to its content size).

Getting this to work well was difficult. In PaymentSheet, we're able to simply apply animateContentSize() to the top-level bottom sheet container and call it a day, but for Link in PS it leads to an awfully janky UI:

demo-animateContentSize.mov

The complications with the Link UX are:

  1. we use NavHost to route (with transition animations) between screens
  2. we have big animated components within screens (read: wallet)
  3. our layout hierarchy includes nested ModalBottomSheetLayouts
  4. we transition between multiple screens, toggling the keyboard between them
  5. we have ephemeral loading states of varying heights

All of the above lead to animations clashing with one another, for example:

  • The soft keyboard is animating away (increasing the height of the bottom sheet), while
  • we navigate to the next screen, which has a short loading screen (reducing the height of the bottom sheet) for ~200 ms, before
  • we render a tall content screen (increasing the bottom sheet height), which has a form field we auto-focus on, causing
  • the soft keyboard to appear again (decreasing the bottom sheet height).
  • Meanwhile, the soft keyboard animations translate the bottom sheet vertically while also animating the IME window insets.

Complicating things further, many animation APIs don't work right when used in a ModalBottomSheetLayout bottom sheet or outright crash in nested ModalBottomSheetLayouts (see demo app):

demo-bottom-sheet-animation-apis.mov

Ultimately, the changes in this PR include:

  1. Keep the Sign Up and Verification screens fixed as fullscreen bottom sheets
  2. Use NavHost transition properties for animating between Link screens
  3. Use AnimatedContent for animating components within Link screens
  4. Have loading screens be sized at the current Link screen size to reduce animations (see LinkLoadingScreen)
  5. Avoid animating Link screen size during keyboard animations
  6. Increase the delay after changing screens and before showing the soft keyboard to avoid animation clashes
  7. Replace the nested ModalBottomSheetLayout with an ElementsBottomSheetLayout adjacent to the other one

Also in this commit history are different approaches I tried that didn't pan out (example). I decided not to rebase them away for posterity.

Motivation

https://jira.corp.stripe.com/browse/LINK_MOBILE-190

Testing

  • Added tests
  • Modified tests
  • Manually verified

Screenshots

Below are recordings of running through the example app.

dbs-demo1.mov
dbs-demo2.mov

Copy link
Contributor

github-actions bot commented May 20, 2025

Diffuse output:

OLD: identity-example-release-base.apk (signature: V1, V2)
NEW: identity-example-release-pr.apk (signature: V1, V2)

          │          compressed          │         uncompressed         
          ├───────────┬───────────┬──────┼───────────┬───────────┬──────
 APK      │ old       │ new       │ diff │ old       │ new       │ diff 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
      dex │   2.1 MiB │   2.1 MiB │  0 B │   4.3 MiB │   4.3 MiB │  0 B 
     arsc │     1 MiB │     1 MiB │  0 B │     1 MiB │     1 MiB │  0 B 
 manifest │   2.3 KiB │   2.3 KiB │  0 B │     8 KiB │     8 KiB │  0 B 
      res │ 302.9 KiB │ 302.9 KiB │  0 B │   457 KiB │   457 KiB │  0 B 
   native │   6.2 MiB │   6.2 MiB │  0 B │  15.8 MiB │  15.8 MiB │  0 B 
    asset │   7.7 KiB │   7.7 KiB │  0 B │   7.4 KiB │   7.4 KiB │  0 B 
    other │  95.7 KiB │  95.7 KiB │ -3 B │ 183.5 KiB │ 183.5 KiB │  0 B 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
    total │   9.8 MiB │   9.8 MiB │ -3 B │  21.8 MiB │  21.8 MiB │  0 B 

 DEX     │ old   │ new   │ diff      
─────────┼───────┼───────┼───────────
   files │     1 │     1 │ 0         
 strings │ 20668 │ 20668 │ 0 (+0 -0) 
   types │  6493 │  6493 │ 0 (+0 -0) 
 classes │  5259 │  5259 │ 0 (+0 -0) 
 methods │ 31489 │ 31489 │ 0 (+0 -0) 
  fields │ 18222 │ 18222 │ 0 (+0 -0) 

 ARSC    │ old  │ new  │ diff 
─────────┼──────┼──────┼──────
 configs │  164 │  164 │  0   
 entries │ 3646 │ 3646 │  0
APK
   compressed    │  uncompressed   │                                           
──────────┬──────┼──────────┬──────┤                                           
 size     │ diff │ size     │ diff │ path                                      
──────────┼──────┼──────────┼──────┼───────────────────────────────────────────
    271 B │ -1 B │    120 B │  0 B │ ∆ META-INF/version-control-info.textproto 
 29.2 KiB │ -1 B │ 64.6 KiB │  0 B │ ∆ META-INF/CERT.SF                        
  1.2 KiB │ -1 B │  1.2 KiB │  0 B │ ∆ META-INF/CERT.RSA                       
──────────┼──────┼──────────┼──────┼───────────────────────────────────────────
 30.6 KiB │ -3 B │   66 KiB │  0 B │ (total)

Comment on lines 210 to 215
// Workaround a race condition where the route changes while the soft keyboard is
// animating in/out. Imagine navigating from screen A to B, where both screens are
// supposed to be equal height. If the soft keyboard closes immediately after the
// screen transition starts, the target height of screen B, which is locked in at
// the start of the animation, will be too short.
sizeTransform = if (isImeAnimating) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Demo of the problem:

demo-isImeAnimating.mov

Comment on lines +130 to +119
// Keep height fixed to reduce animations caused by IME toggling on both
// this screen and Verification screen.
MinScreenHeightBox {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Demo of the problem:

demo-MinScreenHeightBox.mov

Comment on lines +118 to +124
AnimatedContent(
targetState = state.paymentDetailsList.isEmpty(),
transitionSpec = { LinkScreenTransition },
) { isLoading ->
if (isLoading) {
LinkLoadingScreen(Modifier.testTag(WALLET_LOADER_TAG))
} else {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 AnimatedContent smooths out the transition. Demo of the problem:

demo-WalletBody-AnimatedContent.mov

Comment on lines 22 to 27
/**
* Displays a loading spinner in the center of the screen.
* Will match the last screen size using [ProvideLinkScreenSize] to minimize size changes.
*/
@Composable
internal fun LinkLoadingScreen(modifier: Modifier = Modifier) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Demo of the problem below. Note how it goes from full screen → short → medium height. It now goes from full → medium height.

demo-LocalLinkScreenSize.mov

@@ -419,8 +419,15 @@ public final class com/stripe/android/link/ui/ComposableSingletons$LinkButtonKt
public final class com/stripe/android/link/ui/ComposableSingletons$LinkContentKt {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip: review with Hide whitespace enabled

@lng-stripe lng-stripe marked this pull request as ready for review May 20, 2025 20:25
@lng-stripe lng-stripe requested review from a team as code owners May 20, 2025 20:25
@lng-stripe lng-stripe requested review from tianzhao-stripe, carlosmuvi-stripe and tillh-stripe and removed request for tianzhao-stripe May 20, 2025 20:25
Copy link
Collaborator

@carlosmuvi-stripe carlosmuvi-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code looking good, thanks for the hard work! Will get to manual testing today.

Comment on lines 189 to 191
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LinkNavHost(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - maybe we want to move this to its own file?

Comment on lines 40 to 49
@Composable
internal fun ProvideLinkScreenSize(size: IntSize?, content: @Composable () -> Unit) {
val dpSize = with(LocalDensity.current) {
size?.let { DpSize(it.width.toDp(), it.height.toDp()) }
}
CompositionLocalProvider(
LocalLinkScreenSize provides dpSize,
content = content
)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is used outside of the Loading screen composable as well, can we move it (and related functions) to a Screen utilities file?

Also can we add some docs to these composables for future reference?

* A [Box] that enforces a minimum height relative to the screen height.
*/
@Composable
internal fun MinScreenHeightBox(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably make this file more generic and include the composables above.

@lng-stripe lng-stripe force-pushed the lng/dynamic-bottom-sheet branch from 18eb930 to 5d47ad6 Compare May 29, 2025 20:06
@lng-stripe
Copy link
Contributor Author

@carlosmuvi-stripe I rebased, fixed conflicts, and did some minor cleanup/refactoring per request.

Comment on lines +64 to +69
private val LocalLinkScreenSizeInternal = compositionLocalOf<DpSize?> { null }

/**
* Current screen size rendered in [LinkNavHost].
*/
internal val LocalLinkScreenSize: CompositionLocal<DpSize?> = LocalLinkScreenSizeInternal
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note here how LocalLinkScreenSize cannot be provided except by LinkNavHost.

@lng-stripe lng-stripe merged commit 20099ef into master May 30, 2025
13 checks passed
@lng-stripe lng-stripe deleted the lng/dynamic-bottom-sheet branch May 30, 2025 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants