Skip to content
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

Improve animation in payment sheet screen #10524

Open
wants to merge 20 commits into
base: master
Choose a base branch
from

Conversation

cttsai-stripe
Copy link
Contributor

@cttsai-stripe cttsai-stripe commented Mar 29, 2025

Summary

Sync state updates in PaymenSheetScreenContent to prevent glitchy animation.

Motivation

https://jira.corp.stripe.com/browse/MOBILESDK-2857

Root cause is the async state updates in PaymenSheetScreenContent. See the following recording for detailed step through:
Screen_recording_20250327_231246.webm

Testing

  • Added tests
  • Modified tests
  • Manually verified

Screenshots

Before After
animation to update pm screen.webm Screen_recording_20250403_155614.webm

Changelog

[Fixed] Improve animation in payment sheet screen

Copy link
Contributor

github-actions bot commented Mar 29, 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 MiB │     2 MiB │  0 B │   4.1 MiB │   4.1 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.3 KiB │   7.3 KiB │  0 B │   7.1 KiB │   7.1 KiB │  0 B 
    other │  95.1 KiB │  95.1 KiB │ +1 B │ 182.4 KiB │ 182.4 KiB │  0 B 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
    total │   9.6 MiB │   9.6 MiB │ +1 B │  21.6 MiB │  21.6 MiB │  0 B 

 DEX     │ old   │ new   │ diff      
─────────┼───────┼───────┼───────────
   files │     1 │     1 │ 0         
 strings │ 20142 │ 20142 │ 0 (+0 -0) 
   types │  6235 │  6235 │ 0 (+0 -0) 
 classes │  5026 │  5026 │ 0 (+0 -0) 
 methods │ 30047 │ 30047 │ 0 (+0 -0) 
  fields │ 17385 │ 17385 │ 0 (+0 -0) 

 ARSC    │ old  │ new  │ diff 
─────────┼──────┼──────┼──────
 configs │  164 │  164 │  0   
 entries │ 3644 │ 3644 │  0
APK
   compressed    │   uncompressed   │                                           
──────────┬──────┼───────────┬──────┤                                           
 size     │ diff │ size      │ diff │ path                                      
──────────┼──────┼───────────┼──────┼───────────────────────────────────────────
   29 KiB │ +5 B │    64 KiB │  0 B │ ∆ META-INF/CERT.SF                        
  1.2 KiB │ -2 B │   1.2 KiB │  0 B │ ∆ META-INF/CERT.RSA                       
    271 B │ -1 B │     120 B │  0 B │ ∆ META-INF/version-control-info.textproto 
 25.7 KiB │ -1 B │    64 KiB │  0 B │ ∆ META-INF/MANIFEST.MF                    
──────────┼──────┼───────────┼──────┼───────────────────────────────────────────
 56.1 KiB │ +1 B │ 129.4 KiB │  0 B │ (total)

Comment on lines +491 to +483
update = {
button?.updateUiState(currentState)
},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This seems necessary to sync the state instead of updating within the LaunchedEffect

Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you explain what you mean here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Originally it was updated async with

LaunchedEffect(viewModel, button) {
        viewModel.primaryButtonUiState.collect { uiState ->
            withContext(Dispatchers.Main) {
                button?.updateUiState(uiState)
            }
        }
    }

Updating the android view along with recomposition by using this update function can solve the janky animation. I am not sure if this is 100% correct.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm still not quite following, but the code as written doesn't seem dangerous.

@cttsai-stripe cttsai-stripe marked this pull request as ready for review March 31, 2025 07:17
@cttsai-stripe cttsai-stripe requested review from a team as code owners March 31, 2025 07:17
@cttsai-stripe cttsai-stripe force-pushed the cttsai/improve-animation-in-payment-sheet-screen branch from 2192d48 to 1cc7b25 Compare April 2, 2025 06:47
return if (isWalletEnabled || isCompleteFlow) {
null
} else {
if (interactor.state.value.supportedPaymentMethods.singleOrNull()?.code ==
Copy link
Collaborator

Choose a reason for hiding this comment

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

How do we ensure this is called again once the interactor state has changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It works because supportedPaymentMethods does not change with the state. It is determined when the interactor is initialized (if I am not wrong). I can refactor this if we decide to go this path.

The one within ManageSavedPaymentMethods would worry me more. That works because interactor.state.title and walletsState share a same dependency interactor.editing, but we shouldn't have implicit dependency like this.

I did not really find a solution for ManageSavedPaymentMethods. If I roll back the change and collect title as a StateFlow in PaymentSheetScreen, the animation would still look janky (but maybe acceptable?)
Screen_recording_20250402_142456.webm

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't see any animations in the screen recording (maybe I'm missing something).

If we can make progress on this, without making implicit assumptions, I think we should do that.

If the only way to do it is to make implicit assumptions, we could write tests to validate the behavior that exists today in a more end to end/integration style test.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you look closer, the title/headerText actually blinks. But it seems not quite noticeable! I'll roll back and use StateFlow for title, as no implicit assumption is needed in this case.

Comment on lines +491 to +483
update = {
button?.updateUiState(currentState)
},
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you explain what you mean here?

override fun showsWalletsHeader(isCompleteFlow: Boolean): StateFlow<Boolean> {
return interactor.showsWalletsHeader
}
override fun showsWalletsHeader(isCompleteFlow: Boolean, walletsState: WalletsState?) =
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this change necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ideally we should use !interactor.showsWalletsInline(walletsState) instead. But the current showsWalletsHeader is used in many tests. I can create another PR for this so we are not introducing more complexity here.

*
*/
@Composable
internal fun rememberPaymentSheetScreenContentState(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this method take in the viewModel instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I think so! And actually we might want to facilitate combineAsStateFlow and mapAsStateFlow in that case. wdyt? Is there a reason that we should avoid these two helpers?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should avoid combineAsStateFlow and mapAsStateFlow.

Those helpers are hacks! We should use compose to do what they do instead. Combine is just a bunch of collectAsState + manual combination with "normal" code. Map is basically the same story. Just collect + do the transformation as "normal" code.

I don't see a use for them when we have the utilities compose provides.

The hacks are basically how we generate the initial value is not consistent with the mental model of how a state flow works. Calling StateFlow.value should just return a value that's already been calculated. Our hacks for combineAsStateFlow and mapAsStateFlow recompute the value instead 😬

…screen

# Conflicts:
#	paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/PaymentSheetScreen.kt
Comment on lines +245 to +246
// supportedPaymentMethods does not change
if (interactor.state.value.supportedPaymentMethods.singleOrNull()?.code ==
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll refactor interactor.state.value.supportedPaymentMethods to interactor.supportedPaymentMethods in another PR.

override fun showsWalletsHeader(isCompleteFlow: Boolean): StateFlow<Boolean> {
return interactor.showsWalletsHeader
}
override fun showsWalletsHeader(isCompleteFlow: Boolean, walletsState: WalletsState?) =
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ideally we should use !interactor.showsWalletsInline(walletsState) instead. But the current showsWalletsHeader is used in many tests. I can create another PR for this so we are not introducing more complexity here.

Comment on lines +413 to +417
override fun title(isCompleteFlow: Boolean, isWalletEnabled: Boolean): ResolvableString? {
// Should never be used
// headerText of this screen is set in paymentSheetScreenContentState
return interactor.state.value.title
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is there a better way to avoid calling this function? I don't think we want to throw an error though.

Comment on lines +421 to 435
override fun paymentSheetScreenContentState(
type: PaymentSheetFlowType,
walletsState: WalletsState?,
primaryButtonUiState: PrimaryButton.UIState?
): StateFlow<PaymentSheetScreenContentState> {
return interactor.state.mapAsStateFlow { state ->
state.title
PaymentSheetScreenContentState(
showsWalletsHeader = false,
actualWalletsState = null,
headerText = state.title,
currentScreen = this,
primaryButtonUiState = primaryButtonUiState
)
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Most of the screen calls the parent paymentSheetScreenContentState. But we rely on interactor.state here so we need to override the function.

Comment on lines 617 to +618
Espresso.onIdle()
assertThat(activity.buyButton.isEnabled)
.isTrue()
composeTestRule.waitUntil { activity.buyButton.isEnabled }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The weird thing here is that we still need Espresso.onIdle() to pass the test. I suspect this is related to the fact that our PrimaryButton is an AndroidView wrapped by composable.

@cttsai-stripe cttsai-stripe force-pushed the cttsai/improve-animation-in-payment-sheet-screen branch from a3294ee to 1efb380 Compare April 3, 2025 23:38
* PaymentSheetScreenContentState
*
*/
fun paymentSheetScreenContentState(
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think there was a misunderstanding here. What I meant was for this to be abstract, and for each of the screens to implement it. I also meant we should remove the other StateFlows and use only this one.

Copy link
Collaborator

Choose a reason for hiding this comment

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

As is, this implementation has the same issues mentioned here.

@cttsai-stripe cttsai-stripe force-pushed the cttsai/improve-animation-in-payment-sheet-screen branch from 1efb380 to bc6cfd3 Compare April 4, 2025 04:17
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.

2 participants