Prerequisites
Before starting, make sure you have:- Web
- Android
- iOS
- Installed Primer Checkout
- Created your first payment
- Familiarity with layout customization
- A working checkout integration (First Payment)
- Familiarity with the controller pattern
- Understanding of PrimerCheckoutHost for inline integration
- A working checkout integration (First Payment)
- Familiarity with scope-based customization
- Understanding of
PrimerCardFormScopefor card form control
Understanding the card form architecture
- Web
- Android
- iOS
The
<primer-card-form> component provides a customizable card payment interface with PCI-compliant hosted inputs. Here’s how the components relate to each other:Security by designThe card input components (
primer-input-card-number, primer-input-card-expiry, primer-input-cvv) render secure iframes that isolate sensitive card data. This means:- Card data never touches your page’s DOM
- Your integration remains PCI-compliant
- Styling is applied through CSS variables that are passed to the iframe
Key components
| Component | Purpose |
|---|---|
primer-card-form | Container that provides context for all card inputs |
primer-input-card-number | Secure hosted input for card number |
primer-input-card-expiry | Secure hosted input for expiry date |
primer-input-cvv | Secure hosted input for CVV |
primer-input-cardholder-name | Input for cardholder name (optional) |
primer-card-form-submit | Submit button with built-in loading states |
The
PrimerCardFormController gives you full control over the card form. You create the controller, observe its state, and build your own UI with custom TextField inputs bound to the controller.Key state properties
| Property | Type | Description |
|---|---|---|
data | Map<PrimerInputElementType, String> | Current field values |
fieldErrors | List<SyncValidationError>? | Validation errors per field |
isFormValid | Boolean | Whether all required fields pass validation |
isLoading | Boolean | Whether a submission is in progress |
PrimerCardFormScope gives you full control over the card form. You access it through PrimerCheckout’s scope closure and use SDK-managed fields (PrimerCardNumberField, PrimerExpiryDateField, PrimerCvvField, PrimerCardholderNameField) in your own SwiftUI layout. Primer handles validation, formatting, and PCI compliance.Key state properties
| Property | Type | Description |
|---|---|---|
isValid | Bool | Whether all required fields pass validation |
isLoading | Bool | Whether a submission is in progress |
fieldErrors | [PrimerFieldError] | Validation errors per field |
data | PrimerCardFormData | Current field values |
Step 1: Create the card form
- Web
- Android
- iOS
Start by creating a custom card form using the
card-form-content slot:Copy
Ask AI
<primer-checkout client-token="your-client-token">
<primer-main slot="main">
<div slot="payments">
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
<primer-input-card-expiry></primer-input-card-expiry>
<primer-input-cvv></primer-input-cvv>
<primer-input-cardholder-name></primer-input-cardholder-name>
<primer-card-form-submit>Pay Now</primer-card-form-submit>
</div>
</primer-card-form>
</div>
</primer-main>
</primer-checkout>
Component hierarchy mattersAll card input components must be nested inside
<primer-card-form>. Placing them outside breaks the context connection and the form won’t work.Copy
Ask AI
<!-- WRONG: Inputs outside primer-card-form -->
<primer-card-form></primer-card-form>
<primer-input-card-number></primer-input-card-number>
<!-- CORRECT: Inputs inside primer-card-form -->
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
Create a
PrimerCardFormController inside a PrimerCheckoutHost. The controller manages field values, validation, and submission.Copy
Ask AI
@Composable
fun CustomCardFormScreen(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val checkoutState by checkout.state.collectAsStateWithLifecycle()
when (checkoutState) {
is PrimerCheckoutState.Loading -> CircularProgressIndicator()
is PrimerCheckoutState.Ready -> {
PrimerCheckoutHost(
checkout = checkout,
onEvent = { event ->
when (event) {
is PrimerCheckoutEvent.Success -> { /* navigate */ }
is PrimerCheckoutEvent.Failure -> { /* log error */ }
}
},
) {
val cardFormController = rememberCardFormController(checkout)
CustomCardForm(cardFormController)
}
}
}
}
Access the card form scope from
PrimerCheckout and replace the screen property with your own SwiftUI layout:Copy
Ask AI
PrimerCheckout(
clientToken: clientToken,
scope: { checkoutScope in
if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
cardScope.screen = { scope in
AnyView(CustomCardForm(scope: scope))
}
}
}
)
Scope hierarchy mattersSDK-managed fields must be created from the
PrimerCardFormScope instance provided in the screen closure. Using a scope from outside this closure will not work.Step 2: Customize the layout
- Web
- Android
- iOS
Vertical layout (default)
Stack inputs vertically for a clean, mobile-friendly form:Copy
Ask AI
<primer-card-form>
<div slot="card-form-content" class="card-form-vertical">
<primer-input-card-number></primer-input-card-number>
<primer-input-card-expiry></primer-input-card-expiry>
<primer-input-cvv></primer-input-cvv>
<primer-input-cardholder-name></primer-input-cardholder-name>
<primer-card-form-submit>Pay Now</primer-card-form-submit>
</div>
</primer-card-form>
Copy
Ask AI
.card-form-vertical {
display: flex;
flex-direction: column;
gap: var(--primer-space-small);
}
Grouped layout
Place expiry and CVV side by side:Copy
Ask AI
<primer-card-form>
<div slot="card-form-content" class="card-form-grouped">
<primer-input-card-number></primer-input-card-number>
<div class="row">
<primer-input-card-expiry></primer-input-card-expiry>
<primer-input-cvv></primer-input-cvv>
</div>
<primer-input-cardholder-name></primer-input-cardholder-name>
<primer-card-form-submit>Pay Now</primer-card-form-submit>
</div>
</primer-card-form>
Copy
Ask AI
.card-form-grouped {
display: flex;
flex-direction: column;
gap: var(--primer-space-small);
}
.card-form-grouped .row {
display: flex;
gap: var(--primer-space-small);
}
.card-form-grouped .row > * {
flex: 1;
}
Responsive layout
Adapt the layout based on screen size:Copy
Ask AI
.card-form-responsive {
display: flex;
flex-direction: column;
gap: var(--primer-space-small);
}
.card-form-responsive .row {
display: flex;
flex-direction: column;
gap: var(--primer-space-small);
}
@media (min-width: 480px) {
.card-form-responsive .row {
flex-direction: row;
}
.card-form-responsive .row > * {
flex: 1;
}
}
Observe form state and bind each
OutlinedTextField to the controller’s update methods. The controller handles formatting (card number grouping, expiry date slashes) internally.Copy
Ask AI
@Composable
fun CustomCardForm(controller: PrimerCardFormController) {
val formState by controller.state.collectAsStateWithLifecycle()
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = formState.data[PrimerInputElementType.CARD_NUMBER].orEmpty(),
onValueChange = { controller.updateCardNumber(it) },
label = { Text("Card number") },
isError = hasFieldError(formState, PrimerInputElementType.CARD_NUMBER),
supportingText = fieldErrorText(formState, PrimerInputElementType.CARD_NUMBER),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = formState.data[PrimerInputElementType.EXPIRY_DATE].orEmpty(),
onValueChange = { controller.updateExpiryDate(it) },
label = { Text("MM/YY") },
isError = hasFieldError(formState, PrimerInputElementType.EXPIRY_DATE),
supportingText = fieldErrorText(formState, PrimerInputElementType.EXPIRY_DATE),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = formState.data[PrimerInputElementType.CVV].orEmpty(),
onValueChange = { controller.updateCvv(it) },
label = { Text("CVV") },
isError = hasFieldError(formState, PrimerInputElementType.CVV),
supportingText = fieldErrorText(formState, PrimerInputElementType.CVV),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
}
OutlinedTextField(
value = formState.data[PrimerInputElementType.CARDHOLDER_NAME].orEmpty(),
onValueChange = { controller.updateCardholderName(it) },
label = { Text("Cardholder name") },
isError = hasFieldError(formState, PrimerInputElementType.CARDHOLDER_NAME),
supportingText = fieldErrorText(formState, PrimerInputElementType.CARDHOLDER_NAME),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Arrange SDK-managed fields in your own SwiftUI layout. The scope provides field components that you position freely:
Vertical layout
Copy
Ask AI
struct CustomCardForm: View {
let scope: any PrimerCardFormScope
@State private var isValid = false
@State private var isLoading = false
var body: some View {
VStack(spacing: 16) {
scope.PrimerCardNumberField(label: "Card number", styling: nil)
scope.PrimerExpiryDateField(label: "Expiry date", styling: nil)
scope.PrimerCvvField(label: "CVV", styling: nil)
scope.PrimerCardholderNameField(label: "Name on card", styling: nil)
Button(action: { scope.submit() }) {
Text("Pay now")
.frame(maxWidth: .infinity, minHeight: 48)
}
.buttonStyle(.borderedProminent)
.disabled(!isValid || isLoading)
}
.padding()
.task {
for await state in scope.state {
isValid = state.isValid
isLoading = state.isLoading
}
}
}
}
Grouped layout
Place expiry and CVV side by side:Copy
Ask AI
VStack(spacing: 16) {
scope.PrimerCardNumberField(label: "Card number", styling: nil)
HStack(spacing: 12) {
scope.PrimerExpiryDateField(label: "Expiry date", styling: nil)
scope.PrimerCvvField(label: "CVV", styling: nil)
}
scope.PrimerCardholderNameField(label: "Name on card", styling: nil)
}
Step 3: Style the inputs
- Web
- Android
- iOS
Style card inputs using CSS variables. These properties are passed through to the secure iframes:
Copy
Ask AI
primer-card-form {
/* Input styling */
--primer-input-height: 48px;
--primer-input-padding: 12px 16px;
--primer-input-border-radius: 8px;
/* Colors */
--primer-color-background-input-default: #ffffff;
--primer-color-border-input-default: #e0e0e0;
--primer-color-border-input-focus: #2f98ff;
--primer-color-border-input-error: #f44336;
/* Typography */
--primer-input-font-size: 16px;
--primer-input-font-family: system-ui, sans-serif;
--primer-color-text-input: #333333;
--primer-color-text-placeholder: #999999;
}
For a complete list of CSS variables, see the Styling guide.
Adding custom labels
Wrap inputs with labels for better accessibility:Copy
Ask AI
<primer-card-form>
<div slot="card-form-content" class="card-form-with-labels">
<label class="input-group">
<span class="label-text">Card Number</span>
<primer-input-card-number></primer-input-card-number>
</label>
<div class="row">
<label class="input-group">
<span class="label-text">Expiry Date</span>
<primer-input-card-expiry></primer-input-card-expiry>
</label>
<label class="input-group">
<span class="label-text">CVV</span>
<primer-input-cvv></primer-input-cvv>
</label>
</div>
<label class="input-group">
<span class="label-text">Cardholder Name</span>
<primer-input-cardholder-name></primer-input-cardholder-name>
</label>
<primer-card-form-submit>Pay Now</primer-card-form-submit>
</div>
</primer-card-form>
Copy
Ask AI
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.label-text {
font-size: 14px;
font-weight: 500;
color: var(--primer-color-text-primary);
}
Handle validation errors by extracting error messages from
fieldErrors to display under each field:Copy
Ask AI
private fun hasFieldError(
state: PrimerCardFormController.State,
type: PrimerInputElementType,
): Boolean {
return state.fieldErrors?.any { it.inputElementType == type } == true
}
private fun fieldErrorText(
state: PrimerCardFormController.State,
type: PrimerInputElementType,
): (@Composable () -> Unit)? {
val error = state.fieldErrors?.firstOrNull { it.inputElementType == type }
return error?.let {
{ Text(it.errorId) }
}
}
Validation errors appear after the user leaves a field (on blur), not while typing. The SDK handles this timing internally.
Apply
PrimerFieldStyling to individual fields for visual customization:Copy
Ask AI
let fieldStyling = PrimerFieldStyling(
fontSize: 16,
backgroundColor: Color(.secondarySystemBackground),
borderColor: Color(.separator),
focusedBorderColor: .blue,
cornerRadius: 10,
fieldHeight: 48
)
scope.PrimerCardNumberField(label: "Card number", styling: fieldStyling)
scope.PrimerExpiryDateField(label: "Expiry date", styling: fieldStyling)
scope.PrimerCvvField(label: "CVV", styling: fieldStyling)
Validation errors appear automatically beneath each field. The SDK handles error timing — errors show after the user leaves a field, not while typing.
Step 4: Handle events and submission
- Web
- Android
- iOS
Listen for events to provide feedback and handle the payment flow:
Copy
Ask AI
const cardForm = document.querySelector('primer-card-form');
// Handle validation errors
cardForm.addEventListener('primer:card-error', (event) => {
const { inputType, error } = event.detail;
console.log(`Error in ${inputType}: ${error.message}`);
// Show error feedback to user
showFieldError(inputType, error.message);
});
// Handle successful validation
cardForm.addEventListener('primer:card-success', (event) => {
const { inputType } = event.detail;
console.log(`${inputType} is valid`);
// Clear any previous error
clearFieldError(inputType);
});
For comprehensive event handling patterns, see the Events guide.
Programmatic submission
You can submit the card form programmatically instead of using the built-in submit button:Copy
Ask AI
const cardForm = document.querySelector('primer-card-form');
// Your custom submit button
document.getElementById('my-submit-button').addEventListener('click', async () => {
try {
await cardForm.submit();
} catch (error) {
console.error('Submission failed:', error);
}
});
Setting cardholder name programmatically
If you collect the cardholder name elsewhere (e.g., from a shipping form), you can set it programmatically:Copy
Ask AI
const cardForm = document.querySelector('primer-card-form');
// Set cardholder name from your form
const name = document.getElementById('shipping-name').value;
cardForm.setCardholderName(name);
When using
setCardholderName(), you don’t need to include the <primer-input-cardholder-name> component in your form.Call
controller.submit() to tokenize the card data. Disable the button when the form is invalid or a submission is in progress:Copy
Ask AI
Button(
onClick = { controller.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")
}
}
Call
scope.submit() to tokenize the card data. Monitor the form state to control the button and show loading:Copy
Ask AI
Button(action: { scope.submit() }) {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text("Pay now")
}
}
.frame(maxWidth: .infinity, minHeight: 48)
.buttonStyle(.borderedProminent)
.disabled(!isValid || isLoading)
Setting cardholder name programmatically
If you collect the name elsewhere, set it without showing the field:Copy
Ask AI
cardScope.updateCardholderName("Jane Doe")
When using
updateCardholderName(), you don’t need to include PrimerCardholderNameField in your form.Step 5: Handle payment completion
- Web
- Android
- iOS
Listen for the checkout state to handle successful payments:
Copy
Ask AI
const checkout = document.querySelector('primer-checkout');
checkout.addEventListener('primer:state-change', (event) => {
const state = event.detail;
if (state.isSuccessful) {
// Payment completed successfully
showSuccessMessage();
redirectToConfirmation();
}
if (state.paymentFailure) {
// Payment failed
showErrorMessage(state.paymentFailure.message);
}
});
Handle payment outcomes through the
onEvent callback:Copy
Ask AI
PrimerCheckoutHost(
checkout = checkout,
onEvent = { event ->
when (event) {
is PrimerCheckoutEvent.Success -> {
Log.d("Checkout", "Payment ID: ${event.checkoutData.payment.id}")
}
is PrimerCheckoutEvent.Failure -> {
Log.e("Checkout", "Diagnostics: ${event.error.diagnosticsId}")
}
}
},
) {
// Card form content
}
Use
onCompletion on PrimerCheckout to handle the payment result:Copy
Ask AI
PrimerCheckout(
clientToken: clientToken,
scope: { checkoutScope in
if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
cardScope.screen = { scope in
AnyView(CustomCardForm(scope: scope))
}
}
},
onCompletion: { state in
switch state {
case .success(let result):
print("Payment ID: \(result.payment?.id ?? "")")
case .failure(let error):
print("Error: \(error.errorId)")
default:
break
}
}
)
Common mistakes to avoid
- Web
- Android
- iOS
Duplicate card forms
Duplicate card forms
Don’t include both
<primer-card-form> and <primer-payment-method type="PAYMENT_CARD"> in your layout. The payment method component already renders a card form internally.Copy
Ask AI
<!-- WRONG: Duplicate card forms -->
<div slot="payments">
<primer-card-form>...</primer-card-form>
<primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
</div>
<!-- CORRECT: Use one or the other -->
<div slot="payments">
<primer-card-form>...</primer-card-form>
<primer-payment-method type="PAYPAL"></primer-payment-method>
</div>
Inputs outside the form context
Inputs outside the form context
Card input components must be descendants of
<primer-card-form>. They won’t work if placed outside.Copy
Ask AI
<!-- WRONG -->
<div>
<primer-card-form></primer-card-form>
<primer-input-card-number></primer-input-card-number>
</div>
<!-- CORRECT -->
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
Dynamic rendering timing issues
Dynamic rendering timing issues
When rendering card forms dynamically, ensure the parent
<primer-card-form> exists before adding input children:Copy
Ask AI
// WRONG: Adding inputs before the form
container.innerHTML = `
<primer-input-card-number></primer-input-card-number>
<primer-card-form></primer-card-form>
`;
// CORRECT: Form first, then inputs
container.innerHTML = `
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
`;
Missing slot attribute
Missing slot attribute
When customizing the card form layout, always use the
card-form-content slot:Copy
Ask AI
<!-- WRONG: Content not in a slot -->
<primer-card-form>
<div>
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
<!-- CORRECT: Content in the card-form-content slot -->
<primer-card-form>
<div slot="card-form-content">
<primer-input-card-number></primer-input-card-number>
</div>
</primer-card-form>
Forgetting to observe state
Forgetting to observe state
Always collect the controller’s state using
collectAsStateWithLifecycle(). Without it, your UI won’t update when the user types or when validation changes.Copy
Ask AI
// WRONG: State not observed
val formState = controller.state.value
// CORRECT: State observed reactively
val formState by controller.state.collectAsStateWithLifecycle()
Creating the controller outside PrimerCheckoutHost
Creating the controller outside PrimerCheckoutHost
The card form controller must be created within the
PrimerCheckoutHost or PrimerCheckoutSheet scope.Copy
Ask AI
// WRONG: Controller outside host
val controller = rememberCardFormController(checkout)
PrimerCheckoutHost(checkout = checkout, onEvent = {}) {
CustomCardForm(controller)
}
// CORRECT: Controller inside host
PrimerCheckoutHost(checkout = checkout, onEvent = {}) {
val controller = rememberCardFormController(checkout)
CustomCardForm(controller)
}
Using scope outside the screen closure
Using scope outside the screen closure
SDK-managed fields must be created from the scope provided in the
screen closure. Using a different scope reference won’t work.Copy
Ask AI
// WRONG: Using cardScope directly
cardScope.screen = { scope in
AnyView(
VStack {
cardScope.PrimerCardNumberField(label: "Card", styling: nil) // Wrong scope
}
)
}
// CORRECT: Using the scope parameter
cardScope.screen = { scope in
AnyView(
VStack {
scope.PrimerCardNumberField(label: "Card", styling: nil)
}
)
}
Not observing state
Not observing state
Always use
.task with for await to observe the form state. Without it, your UI won’t update when validation changes.Copy
Ask AI
// WRONG: State not observed
Button("Pay") { scope.submit() }
.disabled(false) // Always enabled
// CORRECT: State observed via async stream
Button("Pay") { scope.submit() }
.disabled(!isValid || isLoading)
.task {
for await state in scope.state {
isValid = state.isValid
isLoading = state.isLoading
}
}
Complete example
- Web
- Android
- iOS
Here’s a complete example combining all the concepts:
Copy
Ask AI
<primer-checkout client-token="your-client-token">
<primer-main slot="main">
<div slot="payments">
<h2>Pay with Card</h2>
<primer-card-form>
<div slot="card-form-content" class="custom-card-form">
<label class="input-group">
<span class="label">Card Number</span>
<primer-input-card-number></primer-input-card-number>
</label>
<div class="row">
<label class="input-group">
<span class="label">Expiry</span>
<primer-input-card-expiry></primer-input-card-expiry>
</label>
<label class="input-group">
<span class="label">CVV</span>
<primer-input-cvv></primer-input-cvv>
</label>
</div>
<label class="input-group">
<span class="label">Name on Card</span>
<primer-input-cardholder-name></primer-input-cardholder-name>
</label>
<primer-card-form-submit class="submit-button">
Complete Payment
</primer-card-form-submit>
</div>
</primer-card-form>
<!-- Other payment methods -->
<primer-payment-method type="PAYPAL"></primer-payment-method>
<primer-payment-method type="APPLE_PAY"></primer-payment-method>
</div>
</primer-main>
</primer-checkout>
<style>
.custom-card-form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
background: var(--primer-color-background-outlined-default);
border-radius: 8px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 14px;
font-weight: 500;
color: var(--primer-color-text-primary);
}
.row {
display: flex;
gap: 16px;
}
.row > * {
flex: 1;
}
.submit-button {
margin-top: 8px;
}
@media (max-width: 480px) {
.row {
flex-direction: column;
}
}
</style>
<script>
const cardForm = document.querySelector('primer-card-form');
const checkout = document.querySelector('primer-checkout');
// Handle field errors
cardForm.addEventListener('primer:card-error', (event) => {
const { inputType, error } = event.detail;
const input = cardForm.querySelector(`primer-input-${inputType}`);
input?.classList.add('has-error');
});
// Clear errors on success
cardForm.addEventListener('primer:card-success', (event) => {
const { inputType } = event.detail;
const input = cardForm.querySelector(`primer-input-${inputType}`);
input?.classList.remove('has-error');
});
// Handle payment completion
checkout.addEventListener('primer:state-change', (event) => {
if (event.detail.isSuccessful) {
window.location.href = '/confirmation';
}
});
</script>
Copy
Ask AI
@Composable
fun CustomCardFormScreen(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val checkoutState by checkout.state.collectAsStateWithLifecycle()
when (checkoutState) {
is PrimerCheckoutState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is PrimerCheckoutState.Ready -> {
PrimerCheckoutHost(
checkout = checkout,
onEvent = { event ->
when (event) {
is PrimerCheckoutEvent.Success -> {
Log.d("Checkout", "Payment ID: ${event.checkoutData.payment.id}")
}
is PrimerCheckoutEvent.Failure -> {
Log.e("Checkout", "Diagnostics: ${event.error.diagnosticsId}")
}
}
},
) {
val controller = rememberCardFormController(checkout)
val formState by controller.state.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Enter card details",
style = MaterialTheme.typography.titleMedium,
)
OutlinedTextField(
value = formState.data[PrimerInputElementType.CARD_NUMBER].orEmpty(),
onValueChange = { controller.updateCardNumber(it) },
label = { Text("Card number") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = formState.data[PrimerInputElementType.EXPIRY_DATE].orEmpty(),
onValueChange = { controller.updateExpiryDate(it) },
label = { Text("MM/YY") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = formState.data[PrimerInputElementType.CVV].orEmpty(),
onValueChange = { controller.updateCvv(it) },
label = { Text("CVV") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
}
OutlinedTextField(
value = formState.data[PrimerInputElementType.CARDHOLDER_NAME].orEmpty(),
onValueChange = { controller.updateCardholderName(it) },
label = { Text("Cardholder name") },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { controller.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")
}
}
}
}
}
}
}
Copy
Ask AI
struct CustomCardForm: View {
let scope: any PrimerCardFormScope
@State private var isValid = false
@State private var isLoading = false
var body: some View {
VStack(spacing: 20) {
Text("Payment details")
.font(.title2)
.fontWeight(.semibold)
.frame(maxWidth: .infinity, alignment: .leading)
scope.PrimerCardNumberField(label: "Card number", styling: nil)
HStack(spacing: 12) {
scope.PrimerExpiryDateField(label: "Expiry date", styling: nil)
scope.PrimerCvvField(label: "CVV", styling: nil)
}
scope.PrimerCardholderNameField(label: "Name on card", styling: nil)
Button(action: { scope.submit() }) {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text("Pay now")
}
}
.frame(maxWidth: .infinity, minHeight: 48)
.background(isValid ? Color.blue : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
.disabled(!isValid || isLoading)
}
.padding()
.task {
for await state in scope.state {
isValid = state.isValid
isLoading = state.isLoading
}
}
}
}
// Usage
PrimerCheckout(
clientToken: clientToken,
scope: { checkoutScope in
if let cardScope: PrimerCardFormScope = checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self) {
cardScope.screen = { scope in
AnyView(CustomCardForm(scope: scope))
}
}
},
onCompletion: { state in
switch state {
case .success(let result):
print("Payment ID: \(result.payment?.id ?? "")")
case .failure(let error):
print("Error: \(error.errorId)")
default:
break
}
}
)
See also
Styling guide
Customize colors, fonts, and spacing
Events guide
Handle checkout events across platforms
Error handling
Display and handle payment errors
Android SDK Reference
Android SDK API documentation