From 41baffbe9390abc14bc07b12e79f9170143306ca Mon Sep 17 00:00:00 2001 From: Ruslan Serebriakov Date: Mon, 6 Apr 2026 14:59:07 +0100 Subject: [PATCH] feat: Add FaceLivenessTheme for comprehensive UI customization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new FaceLivenessTheme configuration type that allows consumers to customize the visual appearance of the Face Liveness detection UI without forking the library. All customizations have sensible defaults that preserve the existing UI exactly. ## New API ```swift var theme = FaceLivenessTheme() theme.colors.primaryBackground = .black theme.oval.strokeWidth = 4 theme.oval.maskColor = .black theme.instruction.backgroundColor = .white theme.instruction.textColor = .black theme.instruction.useCapsuleShape = true theme.components.showRecordingIndicator = false theme.components.showCloseButton = false theme.preferredColorScheme = .dark theme.customLoadingView = AnyView(MyLoadingView()) theme.usesCompactCameraPermissionPrompt = true FaceLivenessDetectorView( sessionID: sessionID, region: region, theme: theme, isPresented: $isPresented, onCompletion: { _ in } ) ``` ## Customization Surface ### Colors (FaceLivenessTheme.Colors) All 9 semantic color tokens are now configurable: - primaryBackground, primaryLabel (buttons, active instructions) - background, label (close button, verifying state) - errorBackground, errorLabel (error instructions) - warningBackground, warningLabel (photosensitivity warning) - previewBorder (Get Ready page preview ellipse) ### Oval Overlay (FaceLivenessTheme.OvalStyle) - maskColor: Fill color for the mask area outside the oval - strokeColor: Oval border stroke color - strokeWidth: Oval border line width ### Instruction Pill (FaceLivenessTheme.InstructionStyle) - backgroundColor/textColor: Override all per-state colors uniformly - font: Instruction text font - useCapsuleShape: Capsule vs rounded rectangle - cornerRadius: Corner radius (when not using capsule) - padding: Text padding within the pill ### Component Visibility (FaceLivenessTheme.ComponentVisibility) - showRecordingIndicator: Toggle the red REC badge - showCloseButton: Toggle the close button - showProgressBar: Toggle the face match progress bar ### Additional Options - preferredColorScheme: Force light/dark mode - customLoadingView: Replace the default loading spinner - usesCompactCameraPermissionPrompt: Use system alert instead of full-screen camera permission view ## Freshness Color Check Fix Added forceWhiteFill mechanism to ensure the freshness color overlay always renders against a white background, regardless of theme. The AWS backend validates semi-transparent overlay colors (alpha 0.75-0.9) — a non-white background causes the blended color to differ, failing the liveness check. - OvalView: Added forceWhiteFill property + traitCollectionDidChange for dynamic UIColor support - LivenessViewController: Forces white backgrounds on displayFreshness, reverts to black on completion - _FaceLivenessDetectionView: SwiftUI background switches to white during .displayingFreshness and .faceMatched states ## Backward Compatibility - The theme parameter defaults to .default on all inits - All existing call sites compile unchanged - Default theme values reproduce the exact current UI - Theme is propagated via SwiftUI Environment to all child views - UIKit views (OvalView, LivenessViewController) receive config via explicit init parameters --- Sources/FaceLiveness/FaceLivenessTheme.swift | 263 ++++++++++++++++ .../CameraPermissionView.swift | 5 +- Sources/FaceLiveness/Views/CloseButton.swift | 5 +- .../GetReadyPage/CameraPreviewView.swift | 3 +- .../Views/GetReadyPage/GetReadyPageView.swift | 7 +- .../InstructionContainerView.swift | 107 ++++--- .../Views/Instruction/InstructionView.swift | 23 +- .../Views/Liveness/CameraView.swift | 8 +- .../Liveness/FaceLivenessDetectionView.swift | 280 +++++++++++------- .../Liveness/LivenessViewController.swift | 25 +- .../Liveness/_FaceLivenessDetectionView.swift | 146 +++++++-- Sources/FaceLiveness/Views/OvalView.swift | 33 ++- .../FaceLiveness/Views/RecordingButton.swift | 4 +- Sources/FaceLiveness/Views/WarningBox.swift | 9 +- 14 files changed, 718 insertions(+), 200 deletions(-) create mode 100644 Sources/FaceLiveness/FaceLivenessTheme.swift diff --git a/Sources/FaceLiveness/FaceLivenessTheme.swift b/Sources/FaceLiveness/FaceLivenessTheme.swift new file mode 100644 index 00000000..b71bd466 --- /dev/null +++ b/Sources/FaceLiveness/FaceLivenessTheme.swift @@ -0,0 +1,263 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import SwiftUI +import UIKit + +// MARK: - FaceLivenessTheme + +/// Configuration for customizing the visual appearance of the Face Liveness detection UI. +/// +/// Create a theme with default values and override only the properties you need: +/// ```swift +/// var theme = FaceLivenessTheme() +/// theme.colors.primary = .blue +/// theme.oval.strokeWidth = 4 +/// theme.components.showRecordingIndicator = false +/// ``` +public struct FaceLivenessTheme { + + /// Colors used across the liveness UI. + public var colors: Colors + + /// Configuration for the oval overlay during face detection. + public var oval: OvalStyle + + /// Configuration for the instruction text pill shown during the liveness check. + public var instruction: InstructionStyle + + /// Controls which UI components are visible during the liveness check. + public var components: ComponentVisibility + + /// When non-nil, forces the specified color scheme on all liveness views. + /// Set to `.dark` for a dark-themed UI. Default is `nil` (follows system). + public var preferredColorScheme: ColorScheme? + + /// Custom view to display during loading/connecting states. + /// When `nil`, the default loading spinner is shown. + /// + /// Example: + /// ```swift + /// theme.customLoadingView = AnyView(MyCustomLoadingView()) + /// ``` + public var customLoadingView: AnyView? + + /// When `true`, camera permission prompts use a compact system alert + /// over the loading view instead of the full-screen camera permission view. + /// Default is `false`. + public var usesCompactCameraPermissionPrompt: Bool + + /// Layout style for the detection overlay. Default is `.default` (VStack-based). + /// Use `.fullScreenOval()` for a full-screen layout with a static oval placeholder. + public var layout: LayoutStyle + + /// Creates a theme with all default values matching the standard liveness UI. + public init() { + self.colors = Colors() + self.oval = OvalStyle() + self.instruction = InstructionStyle() + self.components = ComponentVisibility() + self.preferredColorScheme = nil + self.customLoadingView = nil + self.usesCompactCameraPermissionPrompt = false + self.layout = .default + } + + /// The default theme matching the standard liveness UI. + public static let `default` = FaceLivenessTheme() +} + +// MARK: - Colors + +extension FaceLivenessTheme { + /// Color configuration for the liveness UI. + public struct Colors { + /// Color for primary action elements + /// (buttons, active instruction pills, progress bars). + public var primary: Color + + /// Text/icon color used on primary elements. + public var onPrimary: Color + + /// General background color (verifying state). + public var background: Color + + /// Text/icon color on background. + public var onBackground: Color + + /// Component background color (close button, recording indicator). + public var surface: Color + + /// Text/icon color on surface components. + public var onSurface: Color + + /// Color for error instruction states (e.g., "Move face back"). + public var error: Color + + /// Text color for error instruction states. + public var onError: Color + + /// Background color for the photosensitivity warning box. + public var errorContainer: Color + + /// Text color for the photosensitivity warning box. + public var onErrorContainer: Color + + /// Stroke color for the preview ellipse on the Get Ready page. + public var previewBorder: Color + + /// Creates default colors matching the standard liveness UI. + public init() { + self.primary = .livenessPrimaryBackground + self.onPrimary = .livenessPrimaryLabel + self.background = .livenessBackground + self.onBackground = .livenessLabel + self.surface = .livenessBackground + self.onSurface = .livenessLabel + self.error = .livenessErrorBackground + self.onError = .livenessErrorLabel + self.errorContainer = .livenessWarningBackground + self.onErrorContainer = .livenessWarningLabel + self.previewBorder = .livenessPreviewBorder + } + } +} + +// MARK: - OvalStyle + +extension FaceLivenessTheme { + /// Configuration for the oval overlay that frames the user's face. + public struct OvalStyle { + /// Fill color for the mask area outside the oval. + /// Default: white at 90% opacity. + public var maskColor: UIColor + + /// Stroke color for the oval border. Default: white. + public var strokeColor: UIColor + + /// Line width for the oval border stroke. Default: 8. + public var strokeWidth: CGFloat + + /// Creates default oval style matching the standard liveness UI. + public init() { + self.maskColor = UIColor.white.withAlphaComponent(0.9) + self.strokeColor = .white + self.strokeWidth = 8 + } + } +} + +// MARK: - InstructionStyle + +extension FaceLivenessTheme { + /// Configuration for the instruction text shown during the liveness check. + public struct InstructionStyle { + /// Override for instruction background color. + /// When non-nil, all instruction states use this single color. + /// When nil (default), per-state colors from ``Colors`` are used. + public var backgroundColor: Color? + + /// Override for instruction text color. + /// When non-nil, all instruction states use this single color. + /// When nil (default), per-state colors from ``Colors`` are used. + public var textColor: Color? + + /// Font for the instruction text. Default: `.title`. + public var font: Font + + /// When `true`, uses a capsule shape for the instruction background. + /// When `false` (default), uses a rounded rectangle with ``cornerRadius``. + public var useCapsuleShape: Bool + + /// Corner radius for the instruction background. + /// Ignored when ``useCapsuleShape`` is `true`. Default: 8. + public var cornerRadius: CGFloat + + /// Padding around the instruction text. + /// Default: 12 on all edges. + public var padding: EdgeInsets + + /// Creates default instruction style matching the standard liveness UI. + public init() { + self.backgroundColor = nil + self.textColor = nil + self.font = .title + self.useCapsuleShape = false + self.cornerRadius = 8 + self.padding = EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + } + } +} + +// MARK: - ComponentVisibility + +extension FaceLivenessTheme { + /// Controls which UI components are visible during the liveness check. + public struct ComponentVisibility { + /// Whether to show the recording indicator (red dot + "REC" label). + /// Default: `true`. + public var showRecordingIndicator: Bool + + /// Whether to show the close button during the liveness check. + /// Default: `true`. + public var showCloseButton: Bool + + /// Whether to show the progress bar during face matching. + /// Default: `true`. + public var showProgressBar: Bool + + /// Creates default visibility settings (all components visible). + public init() { + self.showRecordingIndicator = true + self.showCloseButton = true + self.showProgressBar = true + } + } +} + +// MARK: - LayoutStyle + +extension FaceLivenessTheme { + /// Controls the layout of the detection overlay during the liveness check. + public enum LayoutStyle { + /// Default layout: VStack with top bar (recording indicator + close button), + /// instruction below the top bar, 3:4 aspect ratio content area. + case `default` + + /// Full-screen layout with a static oval placeholder shown before the SDK + /// draws its dynamic oval. The instruction pill is positioned above the oval. + /// Ideal for seamless transitions from a custom loading view that also + /// shows an oval. + /// + /// - Parameters: + /// - ovalWidth: Width of the static oval placeholder. Default: 250. + /// - ovalHeight: Height of the static oval placeholder. Default: 344. + /// - ovalYRatio: Vertical position of the oval center as a fraction of + /// screen height (0 = top, 1 = bottom). Default: 0.42. + /// - instructionOffset: Distance in points between the instruction pill's + /// bottom edge and the oval's top edge. Default: 30. + case fullScreenOval( + ovalWidth: CGFloat = 250, + ovalHeight: CGFloat = 344, + ovalYRatio: CGFloat = 0.42, + instructionOffset: CGFloat = 30 + ) + } +} + +// MARK: - SwiftUI Environment + +struct LivenessThemeKey: EnvironmentKey { + static let defaultValue = FaceLivenessTheme.default +} + +extension EnvironmentValues { + var livenessTheme: FaceLivenessTheme { + get { self[LivenessThemeKey.self] } + set { self[LivenessThemeKey.self] = newValue } + } +} diff --git a/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift b/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift index ee9f3966..8b789c79 100644 --- a/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift +++ b/Sources/FaceLiveness/Views/CameraPermission/CameraPermissionView.swift @@ -9,6 +9,7 @@ import SwiftUI struct CameraPermissionView: View { @Binding var displayingCameraPermissionsNeededAlert: Bool + @Environment(\.livenessTheme) var theme init( displayingCameraPermissionsNeededAlert: Binding = .constant(false) @@ -60,12 +61,12 @@ struct CameraPermissionView: View { action: goToSettingsAppPage, label: { Text(LocalizedStrings.camera_permission_change_setting_button_title) - .foregroundColor(.livenessPrimaryLabel) + .foregroundColor(theme.colors.onPrimary) .frame(maxWidth: .infinity) } ) .frame(height: 52) - ._background { Color.livenessPrimaryBackground } + ._background { theme.colors.primary } .cornerRadius(14) .padding([.leading, .trailing]) .padding(.bottom, 16) diff --git a/Sources/FaceLiveness/Views/CloseButton.swift b/Sources/FaceLiveness/Views/CloseButton.swift index dee45c40..9b21bc96 100644 --- a/Sources/FaceLiveness/Views/CloseButton.swift +++ b/Sources/FaceLiveness/Views/CloseButton.swift @@ -9,6 +9,7 @@ import SwiftUI struct CloseButton: View { let action: () -> Void + @Environment(\.livenessTheme) var theme var body: some View { Button( @@ -16,9 +17,9 @@ struct CloseButton: View { label: { Image(systemName: "xmark") .font(.system(size: 18, weight: .bold)) - .foregroundColor(.livenessLabel) + .foregroundColor(theme.colors.onSurface) .frame(width: 44, height: 44) - .background(Color.livenessBackground) + .background(theme.colors.surface) .clipShape(Circle()) .accessibilityLabel(Text(LocalizedStrings.close_button_a11y)) } diff --git a/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift b/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift index 19e2f483..201986d2 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/CameraPreviewView.swift @@ -14,6 +14,7 @@ struct CameraPreviewView: View { private static let previewYPositionRatio = 0.6 @StateObject var model: CameraPreviewViewModel + @Environment(\.livenessTheme) var theme init(model: CameraPreviewViewModel = CameraPreviewViewModel(cameraPosition: .front)) { self._model = StateObject(wrappedValue: model) @@ -33,7 +34,7 @@ struct CameraPreviewView: View { }) GeometryReader { geometry in Ellipse() - .stroke(Color.livenessPreviewBorder, style: StrokeStyle(lineWidth: 3)) + .stroke(theme.colors.previewBorder, style: StrokeStyle(lineWidth: 3)) .frame(width: geometry.size.width*Self.previewWidthRatio, height: geometry.size.height*Self.previewHeightRatio) .position(x: geometry.size.width*Self.previewXPositionRatio, diff --git a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift index 5f5279e9..0f9d0ab7 100644 --- a/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift +++ b/Sources/FaceLiveness/Views/GetReadyPage/GetReadyPageView.swift @@ -13,7 +13,8 @@ struct GetReadyPageView: View { let onBegin: () -> Void let challenge: Challenge let cameraPosition: LivenessCamera - + @Environment(\.livenessTheme) var theme + init( onBegin: @escaping () -> Void, beginCheckButtonDisabled: Bool = false, @@ -53,13 +54,13 @@ struct GetReadyPageView: View { action: onBegin, label: { Text(LocalizedStrings.get_ready_begin_check) - .foregroundColor(.livenessPrimaryLabel) + .foregroundColor(theme.colors.onPrimary) .frame(maxWidth: .infinity) } ) .disabled(beginCheckButtonDisabled) .frame(height: 52) - ._background { Color.livenessPrimaryBackground } + ._background { theme.colors.primary } .cornerRadius(14) .padding([.leading, .trailing]) .padding(.bottom, 16) diff --git a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift index 0a4e0367..dd320b08 100644 --- a/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift +++ b/Sources/FaceLiveness/Views/Instruction/InstructionContainerView.swift @@ -11,15 +11,15 @@ import Combine struct InstructionContainerView: View { @ObservedObject var viewModel: FaceLivenessDetectionViewModel + @Environment(\.livenessTheme) var theme var body: some View { switch viewModel.livenessState.state { case .displayingFreshness: - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_instruction_hold_still, - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) .onAppear { UIAccessibility.post( @@ -29,11 +29,10 @@ struct InstructionContainerView: View { } case .awaitingFaceInOvalMatch(.faceTooClose, _): - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_instruction_move_face_back, - backgroundColor: .livenessErrorBackground, - textColor: .livenessErrorLabel, - font: .title + defaultBackgroundColor: theme.colors.error, + defaultTextColor: theme.colors.onError ) .onAppear { UIAccessibility.post( @@ -43,27 +42,27 @@ struct InstructionContainerView: View { } case .awaitingFaceInOvalMatch(let reason, let percentage): - InstructionView( + themedInstructionView( text: .init(reason.localizedValue), - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) - ProgressBarView( - emptyColor: .white, - borderColor: .hex("#AEB3B7"), - fillColor: .livenessPrimaryBackground, - indicatorColor: .livenessPrimaryBackground, - percentage: percentage - ) - .frame(width: 200, height: 30) + if theme.components.showProgressBar { + ProgressBarView( + emptyColor: .white, + borderColor: .hex("#AEB3B7"), + fillColor: theme.colors.primary, + indicatorColor: theme.colors.primary, + percentage: percentage + ) + .frame(width: 200, height: 30) + } case .recording(ovalDisplayed: true): - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_instruction_move_face_closer, - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) .onAppear { UIAccessibility.post( @@ -72,25 +71,27 @@ struct InstructionContainerView: View { ) } - ProgressBarView( - emptyColor: .white, - borderColor: .hex("#AEB3B7"), - fillColor: .livenessPrimaryBackground, - indicatorColor: .livenessPrimaryBackground, - percentage: 0.2 - ) - .frame(width: 200, height: 30) + if theme.components.showProgressBar { + ProgressBarView( + emptyColor: .white, + borderColor: .hex("#AEB3B7"), + fillColor: theme.colors.primary, + indicatorColor: theme.colors.primary, + percentage: 0.2 + ) + .frame(width: 200, height: 30) + } case .pendingFacePreparedConfirmation(let reason): - InstructionView( + themedInstructionView( text: .init(reason.localizedValue), - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) case .completedDisplayingFreshness: - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_verifying, - backgroundColor: .livenessBackground + defaultBackgroundColor: theme.colors.background, + defaultTextColor: theme.colors.onBackground ) .onAppear { UIAccessibility.post( @@ -99,9 +100,10 @@ struct InstructionContainerView: View { ) } case .completedNoLightCheck: - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_verifying, - backgroundColor: .livenessBackground + defaultBackgroundColor: theme.colors.background, + defaultTextColor: theme.colors.onBackground ) .onAppear { UIAccessibility.post( @@ -112,11 +114,10 @@ struct InstructionContainerView: View { case .faceMatched: if let challenge = viewModel.challengeReceived, case .faceMovementAndLightChallenge = challenge { - InstructionView( + themedInstructionView( text: LocalizedStrings.challenge_instruction_hold_still, - backgroundColor: .livenessPrimaryBackground, - textColor: .livenessPrimaryLabel, - font: .title + defaultBackgroundColor: theme.colors.primary, + defaultTextColor: theme.colors.onPrimary ) } else { EmptyView() @@ -125,4 +126,22 @@ struct InstructionContainerView: View { EmptyView() } } + + /// Creates an ``InstructionView`` using theme overrides when set, + /// falling back to the provided per-state default colors. + private func themedInstructionView( + text: String, + defaultBackgroundColor: Color, + defaultTextColor: Color + ) -> InstructionView { + InstructionView( + text: text, + backgroundColor: theme.instruction.backgroundColor ?? defaultBackgroundColor, + textColor: theme.instruction.textColor ?? defaultTextColor, + font: theme.instruction.font, + useCapsuleShape: theme.instruction.useCapsuleShape, + cornerRadius: theme.instruction.cornerRadius, + padding: theme.instruction.padding + ) + } } diff --git a/Sources/FaceLiveness/Views/Instruction/InstructionView.swift b/Sources/FaceLiveness/Views/Instruction/InstructionView.swift index 5311387b..098de9e3 100644 --- a/Sources/FaceLiveness/Views/Instruction/InstructionView.swift +++ b/Sources/FaceLiveness/Views/Instruction/InstructionView.swift @@ -11,14 +11,27 @@ struct InstructionView: View { let text: String let backgroundColor: Color var textColor: Color = .livenessLabel - var font: Font = .body - + var font: Font = .title + var useCapsuleShape: Bool = false + var cornerRadius: CGFloat = 8 + var padding: EdgeInsets = EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + var body: some View { Text(text) .foregroundColor(textColor) .font(font) - .padding(12) - .background(backgroundColor) - .cornerRadius(8) + .multilineTextAlignment(.center) + .padding(padding) + .background(backgroundShape) + } + + @ViewBuilder + private var backgroundShape: some View { + if useCapsuleShape { + Capsule().fill(backgroundColor) + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(backgroundColor) + } } } diff --git a/Sources/FaceLiveness/Views/Liveness/CameraView.swift b/Sources/FaceLiveness/Views/Liveness/CameraView.swift index e984bce4..c8f435c6 100644 --- a/Sources/FaceLiveness/Views/Liveness/CameraView.swift +++ b/Sources/FaceLiveness/Views/Liveness/CameraView.swift @@ -11,18 +11,22 @@ import AWSPredictionsPlugin struct CameraView: UIViewControllerRepresentable { @ObservedObject var faceLivenessDetectionViewModel: FaceLivenessDetectionViewModel + let ovalStyle: FaceLivenessTheme.OvalStyle init( - faceLivenessDetectionViewModel: FaceLivenessDetectionViewModel + faceLivenessDetectionViewModel: FaceLivenessDetectionViewModel, + ovalStyle: FaceLivenessTheme.OvalStyle = .init() ) { self.faceLivenessDetectionViewModel = faceLivenessDetectionViewModel + self.ovalStyle = ovalStyle } func makeUIViewController( context: Context ) -> _LivenessViewController { let livenessViewController = _LivenessViewController( - viewModel: faceLivenessDetectionViewModel + viewModel: faceLivenessDetectionViewModel, + ovalStyle: ovalStyle ) return livenessViewController } diff --git a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift index bad7e609..ac2076f9 100644 --- a/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/FaceLivenessDetectionView.swift @@ -21,16 +21,30 @@ public struct FaceLivenessDetectorView: View { let disableStartView: Bool let challengeOptions: ChallengeOptions + let theme: FaceLivenessTheme let onCompletion: (Result) -> Void let sessionTask: Task + /// Creates a new Face Liveness detection view. + /// + /// - Parameters: + /// - sessionID: The liveness session ID from Amazon Rekognition. + /// - credentialsProvider: Optional custom AWS credentials provider. + /// - region: The AWS region (e.g., "us-east-1"). + /// - disableStartView: When `true`, skips the Get Ready page. Default: `false`. + /// - challengeOptions: Camera and challenge configuration. Default: front camera. + /// - theme: Visual customization for colors, oval, instructions, and component visibility. + /// Default: `.default` (standard liveness UI). + /// - isPresented: Binding to control view dismissal. + /// - onCompletion: Callback with the liveness detection result. public init( sessionID: String, credentialsProvider: AWSCredentialsProvider? = nil, region: String, disableStartView: Bool = false, challengeOptions: ChallengeOptions = .init(), + theme: FaceLivenessTheme = .default, isPresented: Binding, onCompletion: @escaping (Result) -> Void ) { @@ -38,6 +52,7 @@ public struct FaceLivenessDetectorView: View { self._isPresented = isPresented self.onCompletion = onCompletion self.challengeOptions = challengeOptions + self.theme = theme self.sessionTask = Task { let session = try await AWSPredictionsPlugin.startFaceLivenessSession( @@ -79,6 +94,7 @@ public struct FaceLivenessDetectorView: View { region: String, disableStartView: Bool = false, challengeOptions: ChallengeOptions = .init(), + theme: FaceLivenessTheme = .default, isPresented: Binding, onCompletion: @escaping (Result) -> Void, captureSession: LivenessCaptureSession @@ -87,6 +103,7 @@ public struct FaceLivenessDetectorView: View { self._isPresented = isPresented self.onCompletion = onCompletion self.challengeOptions = challengeOptions + self.theme = theme self.sessionTask = Task { let session = try await AWSPredictionsPlugin.startFaceLivenessSession( @@ -116,131 +133,176 @@ public struct FaceLivenessDetectorView: View { } public var body: some View { - switch displayState { - case .awaitingChallengeType: - LoadingPageView() - .onAppear { - Task { - do { - let session = try await sessionTask.value - viewModel.livenessService = session - viewModel.registerServiceEvents(onChallengeTypeReceived: { challenge in - self.displayState = DisplayState.awaitingCameraPermission(challenge) - }) - viewModel.initializeLivenessStream() - } catch let error as FaceLivenessDetectionError { - switch error { - case .unknown: - viewModel.livenessState.unrecoverableStateEncountered(.unknown) - case .sessionTimedOut, - .faceInOvalMatchExceededTimeLimitError, - .countdownFaceTooClose, - .countdownMultipleFaces, - .countdownNoFace: - viewModel.livenessState.unrecoverableStateEncountered(.timedOut) - case .cameraPermissionDenied: - viewModel.livenessState.unrecoverableStateEncountered(.missingVideoPermission) - case .userCancelled: - viewModel.livenessState.unrecoverableStateEncountered(.userCancelled) - case .socketClosed: - viewModel.livenessState.unrecoverableStateEncountered(.socketClosed) - case .cameraNotAvailable: - viewModel.livenessState.unrecoverableStateEncountered(.cameraNotAvailable) - default: + Group { + switch displayState { + case .awaitingChallengeType: + loadingView + .onAppear { + Task { + do { + let session = try await sessionTask.value + viewModel.livenessService = session + viewModel.registerServiceEvents(onChallengeTypeReceived: { challenge in + self.displayState = DisplayState.awaitingCameraPermission(challenge) + }) + viewModel.initializeLivenessStream() + } catch let error as FaceLivenessDetectionError { + switch error { + case .unknown: + viewModel.livenessState.unrecoverableStateEncountered(.unknown) + case .sessionTimedOut, + .faceInOvalMatchExceededTimeLimitError, + .countdownFaceTooClose, + .countdownMultipleFaces, + .countdownNoFace: + viewModel.livenessState.unrecoverableStateEncountered(.timedOut) + case .cameraPermissionDenied: + viewModel.livenessState.unrecoverableStateEncountered(.missingVideoPermission) + case .userCancelled: + viewModel.livenessState.unrecoverableStateEncountered(.userCancelled) + case .socketClosed: + viewModel.livenessState.unrecoverableStateEncountered(.socketClosed) + case .cameraNotAvailable: + viewModel.livenessState.unrecoverableStateEncountered(.cameraNotAvailable) + default: + viewModel.livenessState.unrecoverableStateEncountered(.couldNotOpenStream) + } + } catch { viewModel.livenessState.unrecoverableStateEncountered(.couldNotOpenStream) } - } catch { - viewModel.livenessState.unrecoverableStateEncountered(.couldNotOpenStream) - } - - DispatchQueue.main.async { - if let faceDetector = viewModel.faceDetector as? FaceDetectorShortRange.Model { - faceDetector.setFaceDetectionSessionConfigurationWrapper(configuration: viewModel) + + DispatchQueue.main.async { + if let faceDetector = viewModel.faceDetector as? FaceDetectorShortRange.Model { + faceDetector.setFaceDetectionSessionConfigurationWrapper(configuration: viewModel) + } } } } - } - .onReceive(viewModel.$livenessState) { output in - switch output.state { - case .encounteredUnrecoverableError(let error): - let closeCode = error.webSocketCloseCode ?? .normalClosure - viewModel.livenessService?.closeSocket(with: closeCode) - isPresented = false - onCompletion(.failure(mapError(error))) - default: - break + .onReceive(viewModel.$livenessState) { output in + switch output.state { + case .encounteredUnrecoverableError(let error): + let closeCode = error.webSocketCloseCode ?? .normalClosure + viewModel.livenessService?.closeSocket(with: closeCode) + isPresented = false + onCompletion(.failure(mapError(error))) + default: + break + } } - } - case .awaitingCameraPermission(let challenge): - CameraPermissionView(displayingCameraPermissionsNeededAlert: $displayingCameraPermissionsNeededAlert) + case .awaitingCameraPermission(let challenge): + cameraPermissionContent + .onAppear { + checkCameraPermission(for: challenge) + } + case .awaitingLivenessSession(let challenge): + Color.clear + .onAppear { + Task { + let cameraPosition: LivenessCamera + switch challenge { + case .faceMovementAndLightChallenge: + cameraPosition = challengeOptions.faceMovementAndLightChallengeOption.camera + case .faceMovementChallenge: + cameraPosition = challengeOptions.faceMovementChallengeOption.camera + } + + let newState = disableStartView + ? DisplayState.displayingLiveness + : DisplayState.displayingGetReadyView(challenge, cameraPosition) + guard self.displayState != newState else { return } + self.displayState = newState + } + } + case .displayingGetReadyView(let challenge, let cameraPosition): + GetReadyPageView( + onBegin: { + guard displayState != .displayingLiveness else { return } + displayState = .displayingLiveness + }, + beginCheckButtonDisabled: false, + challenge: challenge, + cameraPosition: cameraPosition + ) .onAppear { - checkCameraPermission(for: challenge) + DispatchQueue.main.async { + UIScreen.main.brightness = 1.0 + } } - case .awaitingLivenessSession(let challenge): - Color.clear + case .displayingLiveness: + _FaceLivenessDetectionView( + viewModel: viewModel, + videoView: { + CameraView( + faceLivenessDetectionViewModel: viewModel, + ovalStyle: theme.oval + ) + } + ) .onAppear { - Task { - let cameraPosition: LivenessCamera - switch challenge { - case .faceMovementAndLightChallenge: - cameraPosition = challengeOptions.faceMovementAndLightChallengeOption.camera - case .faceMovementChallenge: - cameraPosition = challengeOptions.faceMovementChallengeOption.camera - } - - let newState = disableStartView - ? DisplayState.displayingLiveness - : DisplayState.displayingGetReadyView(challenge, cameraPosition) - guard self.displayState != newState else { return } - self.displayState = newState + DispatchQueue.main.async { + UIScreen.main.brightness = 1.0 } } - case .displayingGetReadyView(let challenge, let cameraPosition): - GetReadyPageView( - onBegin: { - guard displayState != .displayingLiveness else { return } - displayState = .displayingLiveness - }, - beginCheckButtonDisabled: false, - challenge: challenge, - cameraPosition: cameraPosition - ) - .onAppear { - DispatchQueue.main.async { - UIScreen.main.brightness = 1.0 + .onDisappear() { + viewModel.stopRecording() + } + .onReceive(viewModel.$livenessState) { output in + switch output.state { + case .completed: + isPresented = false + onCompletion(.success(())) + case .encounteredUnrecoverableError(let error): + let closeCode = error.webSocketCloseCode ?? .normalClosure + viewModel.livenessService?.closeSocket(with: closeCode) + isPresented = false + onCompletion(.failure(mapError(error))) + default: + break + } } } - case .displayingLiveness: - _FaceLivenessDetectionView( - viewModel: viewModel, - videoView: { - CameraView( - faceLivenessDetectionViewModel: viewModel + } + .environment(\.livenessTheme, theme) + .preferredColorScheme(theme.preferredColorScheme) + } + + // MARK: - Loading View + + @ViewBuilder + private var loadingView: some View { + if let customView = theme.customLoadingView { + customView + } else { + LoadingPageView() + } + } + + // MARK: - Camera Permission + + @ViewBuilder + private var cameraPermissionContent: some View { + if theme.usesCompactCameraPermissionPrompt { + loadingView + .alert(isPresented: $displayingCameraPermissionsNeededAlert) { + Alert( + title: Text(LocalizedStrings.camera_setting_alert_title), + message: Text(LocalizedStrings.camera_setting_alert_message), + primaryButton: .default( + Text(LocalizedStrings.camera_setting_alert_update_setting_button_text).bold(), + action: { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }), + secondaryButton: .default( + Text(LocalizedStrings.camera_setting_alert_not_now_button_text) + ) ) } + } else { + CameraPermissionView( + displayingCameraPermissionsNeededAlert: $displayingCameraPermissionsNeededAlert ) - .onAppear { - DispatchQueue.main.async { - UIScreen.main.brightness = 1.0 - } - } - .onDisappear() { - viewModel.stopRecording() - } - .onReceive(viewModel.$livenessState) { output in - switch output.state { - case .completed: - isPresented = false - onCompletion(.success(())) - case .encounteredUnrecoverableError(let error): - let closeCode = error.webSocketCloseCode ?? .normalClosure - viewModel.livenessService?.closeSocket(with: closeCode) - isPresented = false - onCompletion(.failure(mapError(error))) - default: - break - } - } } } diff --git a/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift b/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift index 96c31149..9ce8bede 100644 --- a/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift +++ b/Sources/FaceLiveness/Views/Liveness/LivenessViewController.swift @@ -13,6 +13,7 @@ import Amplify final class _LivenessViewController: UIViewController { let viewModel: FaceLivenessDetectionViewModel + let ovalStyle: FaceLivenessTheme.OvalStyle var previewLayer: CALayer? let faceShapeLayer = CAShapeLayer() @@ -23,9 +24,11 @@ final class _LivenessViewController: UIViewController { var readyForOval = false init( - viewModel: FaceLivenessDetectionViewModel + viewModel: FaceLivenessDetectionViewModel, + ovalStyle: FaceLivenessTheme.OvalStyle = .init() ) { self.viewModel = viewModel + self.ovalStyle = ovalStyle super.init(nibName: nil, bundle: nil) viewModel.livenessViewControllerDelegate = self viewModel.normalizeFace = { [weak self] face in @@ -129,7 +132,15 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { } func displayFreshness(colorSequences: [FaceLivenessSession.DisplayColor]) { - self.ovalView?.setNeedsDisplay() + // Force white backgrounds during the freshness color check. + // The AWS backend validates the visible overlay colors, which are + // semi-transparent (alpha 0.75–0.9). If the background behind them + // is anything other than white, the blended color will differ and + // the liveness check may fail. + view.backgroundColor = .white + ovalView?.forceWhiteFill = true + ovalView?.setNeedsDisplay() + DispatchQueue.main.async { [weak self] in self?.viewModel.livenessState.displayingFreshness() } @@ -145,6 +156,11 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { guard let self else { return } self.freshnessView.removeFromSuperview() + // Revert to normal background after freshness completes + self.view.backgroundColor = .black + self.ovalView?.forceWhiteFill = false + self.ovalView?.setNeedsDisplay() + self.viewModel.handleFreshnessComplete() } ) @@ -157,7 +173,10 @@ extension _LivenessViewController: FaceLivenessViewControllerPresenter { let ovalView = OvalView( frame: previewLayer.frame, - ovalFrame: ovalRect + ovalFrame: ovalRect, + maskColor: self.ovalStyle.maskColor, + strokeColor: self.ovalStyle.strokeColor, + strokeWidth: self.ovalStyle.strokeWidth ) self.ovalView = ovalView ovalView.center = previewLayer.position diff --git a/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift b/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift index 5113bf54..e68e4284 100644 --- a/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift +++ b/Sources/FaceLiveness/Views/Liveness/_FaceLivenessDetectionView.swift @@ -11,6 +11,7 @@ struct _FaceLivenessDetectionView: View { let videoView: VideoView @ObservedObject var viewModel: FaceLivenessDetectionViewModel @Binding var displayResultsView: Bool + @Environment(\.livenessTheme) var theme init( viewModel: FaceLivenessDetectionViewModel, @@ -25,37 +26,142 @@ struct _FaceLivenessDetectionView: View { ) } + /// During the freshness color check the AWS backend validates overlay + /// colors that are semi-transparent. A white background is required + /// so the blended colors match the expected values. + private var isFreshnessActive: Bool { + switch viewModel.livenessState.state { + case .displayingFreshness, .faceMatched: + return true + default: + return false + } + } + + /// Whether the SDK has drawn its dynamic oval (hide the static placeholder). + private var shouldShowStaticOval: Bool { + switch viewModel.livenessState.state { + case .recording(ovalDisplayed: true), + .awaitingFaceInOvalMatch(_, _), + .faceMatched, + .displayingFreshness, + .completedDisplayingFreshness, + .completedNoLightCheck: + return false + default: + return true + } + } + var body: some View { ZStack { - Color.black - ZStack { - videoView - VStack { - HStack(alignment: .top) { - if viewModel.livenessState.shouldDisplayRecordingIcon { - RecordingButton() - .accessibilityHidden(true) - } - - Spacer() + (isFreshnessActive ? Color.white : Color.black) + switch theme.layout { + case .default: + defaultLayout + case .fullScreenOval(let ovalWidth, let ovalHeight, let ovalYRatio, let instructionOffset): + fullScreenOvalLayout( + ovalWidth: ovalWidth, + ovalHeight: ovalHeight, + ovalYRatio: ovalYRatio, + instructionOffset: instructionOffset + ) + } + } + .edgesIgnoringSafeArea(.all) + } + + // MARK: - Default Layout (VStack, top bar, 3:4 aspect ratio) + + private var defaultLayout: some View { + ZStack { + videoView + VStack { + HStack(alignment: .top) { + if theme.components.showRecordingIndicator, + viewModel.livenessState.shouldDisplayRecordingIcon { + RecordingButton() + .accessibilityHidden(true) + } + Spacer() + + if theme.components.showCloseButton { CloseButton( action: viewModel.closeButtonAction ) } - .padding() + } + .padding() - InstructionContainerView( - viewModel: viewModel - ) + InstructionContainerView( + viewModel: viewModel + ) - Spacer() + Spacer() + } + .padding([.leading, .trailing]) + .aspectRatio(3/4, contentMode: .fit) + .frame(maxWidth: .infinity) + } + } + + // MARK: - Full-Screen Oval Layout (GeometryReader, static oval, instruction above) + + private func fullScreenOvalLayout( + ovalWidth: CGFloat, + ovalHeight: CGFloat, + ovalYRatio: CGFloat, + instructionOffset: CGFloat + ) -> some View { + ZStack { + videoView + + GeometryReader { geometry in + let ovalCenter = CGPoint( + x: geometry.size.width / 2, + y: geometry.size.height * ovalYRatio + ) + let ovalSize = CGSize(width: ovalWidth, height: ovalHeight) + let ovalTopY = ovalCenter.y - ovalHeight / 2 + + // Static oval overlay (visible before SDK draws its dynamic oval) + if shouldShowStaticOval { + OvalCutoutOverlay(ovalSize: ovalSize, ovalCenter: ovalCenter) + .fill(Color.black, style: FillStyle(eoFill: true)) + + Ellipse() + .stroke(Color.white, lineWidth: theme.oval.strokeWidth) + .frame(width: ovalWidth, height: ovalHeight) + .position(ovalCenter) } - .padding([.leading, .trailing]) - .aspectRatio(3/4, contentMode: .fit) - .frame(maxWidth: .infinity) + + // Instruction pill positioned above the oval + InstructionContainerView(viewModel: viewModel) + .position(x: geometry.size.width / 2, y: ovalTopY - instructionOffset) } + .ignoresSafeArea() } - .edgesIgnoringSafeArea(.all) + } +} + +// MARK: - Oval Cutout Shape + +/// Shape that creates a mask overlay with an oval cutout using even-odd fill. +struct OvalCutoutOverlay: Shape { + let ovalSize: CGSize + let ovalCenter: CGPoint + + func path(in rect: CGRect) -> Path { + var path = Path() + path.addRect(rect) + let ovalRect = CGRect( + x: ovalCenter.x - ovalSize.width / 2, + y: ovalCenter.y - ovalSize.height / 2, + width: ovalSize.width, + height: ovalSize.height + ) + path.addEllipse(in: ovalRect) + return path } } diff --git a/Sources/FaceLiveness/Views/OvalView.swift b/Sources/FaceLiveness/Views/OvalView.swift index 60a6e18a..2903af54 100644 --- a/Sources/FaceLiveness/Views/OvalView.swift +++ b/Sources/FaceLiveness/Views/OvalView.swift @@ -10,9 +10,26 @@ import UIKit class OvalView: UIView { let ovalFrame: CGRect + let maskColor: UIColor + let ovalStrokeColor: UIColor + let ovalStrokeWidth: CGFloat - init(frame: CGRect, ovalFrame: CGRect) { + /// When `true`, forces white mask fill regardless of ``maskColor``. + /// Used during the freshness color check to ensure the overlay colors + /// blend correctly against a known white background. + var forceWhiteFill = false + + init( + frame: CGRect, + ovalFrame: CGRect, + maskColor: UIColor = UIColor.white.withAlphaComponent(0.9), + strokeColor: UIColor = .white, + strokeWidth: CGFloat = 8 + ) { self.ovalFrame = ovalFrame + self.maskColor = maskColor + self.ovalStrokeColor = strokeColor + self.ovalStrokeWidth = strokeWidth super.init(frame: frame) backgroundColor = .clear } @@ -22,14 +39,22 @@ class OvalView: UIView { let oval = UIBezierPath(ovalIn: ovalFrame) mask.append(oval.reversing()) - UIColor.white.withAlphaComponent(0.9).setFill() + let fillColor = forceWhiteFill ? UIColor.white : maskColor + fillColor.setFill() mask.fill() UIColor.clear.setFill() - UIColor.white.setStroke() - oval.lineWidth = 8 + ovalStrokeColor.setStroke() + oval.lineWidth = ovalStrokeWidth oval.stroke() } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + setNeedsDisplay() + } + } + required init?(coder: NSCoder) { nil } } diff --git a/Sources/FaceLiveness/Views/RecordingButton.swift b/Sources/FaceLiveness/Views/RecordingButton.swift index 0a157278..76c8c531 100644 --- a/Sources/FaceLiveness/Views/RecordingButton.swift +++ b/Sources/FaceLiveness/Views/RecordingButton.swift @@ -8,6 +8,8 @@ import SwiftUI struct RecordingButton: View { + @Environment(\.livenessTheme) var theme + var body: some View { VStack(alignment: .center) { Circle() @@ -19,7 +21,7 @@ struct RecordingButton: View { } .padding([.top, .bottom], 12) .padding([.leading, .trailing], 8) - .background(Color.livenessBackground) + .background(theme.colors.surface) .cornerRadius(8) } } diff --git a/Sources/FaceLiveness/Views/WarningBox.swift b/Sources/FaceLiveness/Views/WarningBox.swift index 050c2aa4..22215b4a 100644 --- a/Sources/FaceLiveness/Views/WarningBox.swift +++ b/Sources/FaceLiveness/Views/WarningBox.swift @@ -9,6 +9,7 @@ import SwiftUI struct WarningBox: View { @State var isPresentingPopover = false + @Environment(\.livenessTheme) var theme let titleText: String let bodyText: String let popoverContent: PopoverView @@ -28,17 +29,17 @@ struct WarningBox: View { VStack(alignment: .leading) { Text(titleText) .fontWeight(.semibold) - .foregroundColor(.livenessWarningLabel) + .foregroundColor(theme.colors.onErrorContainer) Text(bodyText) - .foregroundColor(.livenessWarningLabel) + .foregroundColor(theme.colors.onErrorContainer) } Spacer() Button( action: { isPresentingPopover = true }, label: { Image(systemName: "info.circle") - .foregroundColor(.livenessWarningLabel) + .foregroundColor(theme.colors.onErrorContainer) .frame(width: 20, height: 20) } ) @@ -53,7 +54,7 @@ struct WarningBox: View { .padding() .background( Rectangle() - .foregroundColor(.livenessWarningBackground) + .foregroundColor(theme.colors.errorContainer) .cornerRadius(6) ) }