Recipe
- Web
- Android
- iOS
checkout.addEventListener('primer:state-change', (event) => {
const { isProcessing } = event.detail;
if (isProcessing) {
document.getElementById('loading-overlay').style.display = 'block';
} else {
document.getElementById('loading-overlay').style.display = 'none';
}
});
Loading during initialization
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
when (state) {
is PrimerCheckoutState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
is PrimerCheckoutState.Ready -> {
PrimerCheckoutSheet(checkout = checkout)
}
}
Loading during form submission
val cardFormController = rememberCardFormController(checkout)
val formState by cardFormController.state.collectAsStateWithLifecycle()
Button(
onClick = { cardFormController.submit() },
enabled = formState.isFormValid && !formState.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (formState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text("Pay")
}
}
Loading during initialization
PrimerCheckout renders Primer’s managed loading screen while the session initializes. For a custom inline layout, observe the session’s phase and show your own loading UI until it becomes .ready:@StateObject private var session: PrimerCheckoutSession
var body: some View {
ScrollView {
if session.phase == .initializing {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
Text("Processing your payment...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
PrimerCardForm()
}
}
.primerCheckoutSession(session) { state in
handle(state)
}
}
Loading during form submission
Drive the submit button from thePrimerCardFormSession injected into the slot, using state.isLoading and state.isValid:PrimerCardForm(submitButton: { session in
Button(action: { session.submit() }) {
if session.state.isLoading {
ProgressView()
.tint(.white)
} else {
Text("Pay")
}
}
.frame(maxWidth: .infinity, minHeight: 48)
.buttonStyle(.borderedProminent)
.disabled(!session.state.isValid || session.state.isLoading)
})
How it works
- Web
- Android
- iOS
- Listen for the
primer:state-changeevent - Check the
isProcessingproperty from the event detail - Show your loading UI when
isProcessingistrue - Hide it when
isProcessingisfalse
The SDK provides two loading states:
| State Source | Property | When It’s True |
|---|---|---|
PrimerCheckoutController.state | PrimerCheckoutState.Loading | SDK initializing, fetching configuration |
PrimerCardFormController.state | isLoading | Payment is being processed after submit |
The SDK provides two loading states:
| State Source | Property | When It’s Active |
|---|---|---|
PrimerCheckoutSession | phase == .initializing | SDK initializing, fetching configuration |
PrimerCardFormSession.state | isLoading | Payment is being processed after submit |
Variations
Custom loading screen
- Web
- Android
- iOS
const overlay = document.getElementById('loading-overlay');
checkout.addEventListener('primer:state-change', (event) => {
overlay.classList.toggle('visible', event.detail.isProcessing);
});
.loading-overlay {
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
}
.loading-overlay.visible {
opacity: 1;
visibility: visible;
}
Override the
loading slot in PrimerCheckoutSheet to replace the default loading screen:PrimerCheckoutSheet(
checkout = checkout,
loading = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Text(
text = "Preparing your checkout...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onDismiss = { },
)
The managed
PrimerCheckout renders its own loading screen. To show a branded loading view, compose your UI inline with a PrimerCheckoutSession and render your view while phase is .initializing:@StateObject private var session: PrimerCheckoutSession
var body: some View {
ScrollView {
if session.phase == .initializing {
VStack(spacing: 16) {
ProgressView()
.controlSize(.large)
.tint(.accentColor)
Text("Preparing your checkout...")
.font(.body)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.ultraThinMaterial)
} else {
PrimerCardForm()
}
}
.primerCheckoutSession(session) { state in
handle(state)
}
}
Inline loading overlay
- Web
- Android
- iOS
checkout.addEventListener('primer:state-change', (event) => {
const { isProcessing } = event.detail;
// Disable all inputs on the page during processing
document.querySelectorAll('input, button, select').forEach((el) => {
el.disabled = isProcessing;
});
});
For inline integrations, you manage the loading UI yourself. This example shows a semi-transparent overlay during form submission:
@Composable
fun InlineCheckoutWithLoading(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (state) {
is PrimerCheckoutState.Success -> { }
is PrimerCheckoutState.Failure -> { }
else -> Unit
}
}
when (state) {
is PrimerCheckoutState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text("Loading payment methods...")
}
}
}
is PrimerCheckoutState.Ready -> {
PrimerCheckoutHost(
checkout = checkout,
) {
val cardFormController = rememberCardFormController(checkout)
val formState by cardFormController.state.collectAsStateWithLifecycle()
Box {
Column(Modifier.padding(16.dp)) {
PrimerCardForm(
controller = cardFormController,
submitButton = {
Button(
onClick = { cardFormController.submit() },
enabled = formState.isFormValid && !formState.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (formState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text("Pay")
}
}
},
)
}
if (formState.isLoading) {
Box(
modifier = Modifier
.matchParentSize()
.background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier.padding(32.dp),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text("Processing payment...")
}
}
}
}
}
}
}
}
}
For inline integrations, you manage the loading UI yourself. This example overlays a semi-transparent loading card during form submission, reading
isLoading from the PrimerCardFormSession:struct InlineCheckoutWithLoading: View {
@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(wrappedValue: PrimerCheckoutSession(clientToken: clientToken))
}
var body: some View {
ScrollView {
PrimerCardForm(
cardDetails: { session in
VStack(spacing: 16) {
CardFormDefaults.cardNumber(session)
HStack(spacing: 12) {
CardFormDefaults.expiryDate(session)
CardFormDefaults.cvv(session)
}
}
},
submitButton: { session in
SubmitWithOverlay(session: session)
}
)
.padding()
}
.primerCheckoutSession(session) { state in
// Handle the terminal PrimerCheckoutState.
}
}
}
struct SubmitWithOverlay: View {
@ObservedObject var session: PrimerCardFormSession
var body: some View {
ZStack {
Button(action: { session.submit() }) {
Text("Pay")
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.borderedProminent)
.disabled(!session.state.isValid || session.state.isLoading)
if session.state.isLoading {
Color.black.opacity(0.3)
.ignoresSafeArea()
VStack(spacing: 12) {
ProgressView()
Text("Processing payment...")
.font(.subheadline)
}
.padding(24)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
}
}
Show processing message
- Web
- Android
- iOS
checkout.addEventListener('primer:state-change', (event) => {
const statusEl = document.getElementById('status-message');
if (event.detail.isProcessing) {
statusEl.textContent = 'Processing your payment...';
statusEl.className = 'status processing';
} else if (event.detail.isSuccessful) {
statusEl.textContent = 'Payment successful!';
statusEl.className = 'status success';
} else {
statusEl.textContent = '';
statusEl.className = 'status';
}
});
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
val cardFormController = rememberCardFormController(checkout)
val formState by cardFormController.state.collectAsStateWithLifecycle()
when {
state is PrimerCheckoutState.Loading -> Text("Loading payment methods...")
formState.isLoading -> Text("Processing payment...")
}
Observe the session
phase and the card form session’s state.isLoading to drive a status message:struct StatusMessage: View {
@ObservedObject var session: PrimerCheckoutSession
var body: some View {
if session.phase == .initializing {
Text("Loading payment methods...")
} else if let cardForm = session.cardForm, cardForm.state.isLoading {
Text("Processing payment...")
}
}
}
See also
Disable external buttons
Prevent double submission during payment processing
Events guide
Handle payment lifecycle events