Skip to content

Commit

Permalink
IDPROD-3401: ID scanning animation + haptics (#832)
Browse files Browse the repository at this point in the history
- Adds a gradient animation that displays when
  a document is detected but is not yet clear
  enough to get a good scan
- Gives haptic feedback when the document has
  been scanned and the checkmark appears
  • Loading branch information
mludowise-stripe authored Mar 13, 2022
1 parent c361720 commit 2050bda
Show file tree
Hide file tree
Showing 12 changed files with 308 additions and 18 deletions.
27 changes: 26 additions & 1 deletion StripeIdentity/StripeIdentity.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@
E6E146D326950E64007BDCD8 /* StripeiOS-Shared.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = E6E146C926950E64007BDCD8 /* StripeiOS-Shared.xcconfig */; };
E6E146D426950E64007BDCD8 /* Project-Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = E6E146CA26950E64007BDCD8 /* Project-Debug.xcconfig */; };
E6E146D526950E64007BDCD8 /* StripeiOS-Release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = E6E146CB26950E64007BDCD8 /* StripeiOS-Release.xcconfig */; };
E6E4AF4627C1C99200F55330 /* AnimatedBorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E4AF4527C1C99200F55330 /* AnimatedBorderView.swift */; };
E6E4AF4827C1D16C00F55330 /* AnimatedBorderViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E4AF4727C1D16C00F55330 /* AnimatedBorderViewSnapshotTest.swift */; };
F311D59827A275E800AD8123 /* IdentityUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F311D59727A275E800AD8123 /* IdentityUI.swift */; };
F311D59A27A2786100AD8123 /* HeaderViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F311D59927A2786100AD8123 /* HeaderViewSnapshotTest.swift */; };
F311D59E27A279B700AD8123 /* SnapshotTestMockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F311D59D27A279B700AD8123 /* SnapshotTestMockData.swift */; };
Expand Down Expand Up @@ -357,6 +359,8 @@
E6E146C926950E64007BDCD8 /* StripeiOS-Shared.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "StripeiOS-Shared.xcconfig"; sourceTree = "<group>"; };
E6E146CA26950E64007BDCD8 /* Project-Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = "<group>"; };
E6E146CB26950E64007BDCD8 /* StripeiOS-Release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "StripeiOS-Release.xcconfig"; sourceTree = "<group>"; };
E6E4AF4527C1C99200F55330 /* AnimatedBorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedBorderView.swift; sourceTree = "<group>"; };
E6E4AF4727C1D16C00F55330 /* AnimatedBorderViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedBorderViewSnapshotTest.swift; sourceTree = "<group>"; };
F311D59727A275E800AD8123 /* IdentityUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityUI.swift; sourceTree = "<group>"; };
F311D59927A2786100AD8123 /* HeaderViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderViewSnapshotTest.swift; sourceTree = "<group>"; };
F311D59D27A279B700AD8123 /* SnapshotTestMockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTestMockData.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -561,7 +565,7 @@
E6548F40272CAC0500F399B2 /* Views */ = {
isa = PBXGroup;
children = (
E6A50DAB27B30E9000D7BDED /* CameraScanningView.swift */,
E6E4AF4427C1C97B00F55330 /* CameraScanningView */,
E627AEF4275851640048F88D /* DocumentCaptureView.swift */,
F383FB4427B5D20E00E19E52 /* ErrorView.swift */,
F3680DF827A9B72B00A8796A /* HeaderIconView.swift */,
Expand Down Expand Up @@ -762,6 +766,7 @@
E6A50DBD27B77F9D00D7BDED /* NSAttributedString_HTMLSnapshotTest.swift */,
E6AF1EE8269FDA020091BE99 /* VerificationFlowWebViewSnapshotTests.swift */,
E6D6576A27BBB3C600AF4212 /* SuccessViewControllerSnapshotTest.swift */,
E6E4AF4727C1D16C00F55330 /* AnimatedBorderViewSnapshotTest.swift */,
E6D6577C27BF314200AF4212 /* InstructionListViewSnapshotTest.swift */,
);
path = Snapshot;
Expand Down Expand Up @@ -898,6 +903,24 @@
path = Source;
sourceTree = "<group>";
};
E6E4AF3527C08CEB00F55330 /* Detectors */ = {
isa = PBXGroup;
children = (
E6C7BB1F27A8BE2E000807A6 /* IDDetector */,
E6E4AF3627C08D0A00F55330 /* MotionBlurDetector.swift */,
);
path = Detectors;
sourceTree = "<group>";
};
E6E4AF4427C1C97B00F55330 /* CameraScanningView */ = {
isa = PBXGroup;
children = (
E6A50DAB27B30E9000D7BDED /* CameraScanningView.swift */,
E6E4AF4527C1C99200F55330 /* AnimatedBorderView.swift */,
);
path = CameraScanningView;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXHeadersBuildPhase section */
Expand Down Expand Up @@ -1073,6 +1096,7 @@
buildActionMask = 2147483647;
files = (
E60FD5D527AE17A100ED64A1 /* InstructionListView.swift in Sources */,
E6E4AF4627C1C99200F55330 /* AnimatedBorderView.swift in Sources */,
E6548F4C2731D6CB00F399B2 /* VerificationPageDataStore.swift in Sources */,
E666A52C273A0AE6001DE130 /* InstructionalCameraScanningView.swift in Sources */,
E6AF1ED4269FD7BB0091BE99 /* VerificationFlowWebView.swift in Sources */,
Expand Down Expand Up @@ -1197,6 +1221,7 @@
E666A51627361C91001DE130 /* DocumentTypeSelectViewControllerTest.swift in Sources */,
E606937A270435FF00742859 /* IdentityElementsFactoryTest.swift in Sources */,
E6AF1EE5269FD9970091BE99 /* VerificationSheetAnalyticsTest.swift in Sources */,
E6E4AF4827C1D16C00F55330 /* AnimatedBorderViewSnapshotTest.swift in Sources */,
E6548EF52729BE6B00F399B2 /* IdentityMockData.swift in Sources */,
E662FC8E278E3094005B0DAD /* ListViewSnapshotTest.swift in Sources */,
E67A1E53273DF31100977F63 /* DocumentScannerMock.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ struct IdentityUI {

// MARK: Colors

static var containerColor = UIColor.dynamic(
static let containerColor = UIColor.dynamic(
light: UIColor(red: 0.969, green: 0.98, blue: 0.988, alpha: 1),
dark: UIColor(red: 0.11, green: 0.11, blue: 0.118, alpha: 1)
)

static let stripeBlurple = UIColor(red: 0.33, green: 0.41, blue: 0.83, alpha: 1)

// MARK: Separator

static let separatorColor = CompatibleColor.separator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class DocumentCaptureViewController: IdentityFlowViewController {
}

updateUI()
generateFeedbackIfNeededForStateChange()
}
}

Expand Down Expand Up @@ -89,7 +90,13 @@ final class DocumentCaptureViewController: IdentityFlowViewController {
))
case .scanning(let documentSide, let foundClassification):
return .scan(.init(
scanningViewModel: .videoPreview(cameraSession),
scanningViewModel: .videoPreview(
cameraSession,
animateBorder: foundClassification?.matchesDocument(
type: documentType,
side: documentSide
) ?? false
),
instructionalText: scanningInstructionText(
for: documentSide,
foundClassification: foundClassification
Expand Down Expand Up @@ -228,6 +235,7 @@ final class DocumentCaptureViewController: IdentityFlowViewController {
let apiConfig: VerificationPageStaticContentDocumentCapturePage
let documentType: DocumentType
var timeoutTimer: Timer?
private var feedbackGenerator: UINotificationFeedbackGenerator?

// MARK: Coordinators
let scanner: DocumentScannerProtocol
Expand Down Expand Up @@ -327,6 +335,14 @@ extension DocumentCaptureViewController {
documentCaptureView.configure(with: viewModel)
}

func generateFeedbackIfNeededForStateChange() {
guard case .scanned = state else {
return
}

feedbackGenerator?.notificationOccurred(.success)
}

// MARK: - Notifications

func addObservers() {
Expand Down Expand Up @@ -406,6 +422,10 @@ extension DocumentCaptureViewController {
// Focus the accessibility VoiceOver back onto the capture view
UIAccessibility.post(notification: .layoutChanged, argument: self.documentCaptureView)

// Prepare feedback generators
self.feedbackGenerator = UINotificationFeedbackGenerator()
self.feedbackGenerator?.prepare()

cameraSession.startSession(completeOn: .main) { [weak self] in
guard let self = self else { return }
self.timeoutTimer = Timer.scheduledTimer(
Expand All @@ -422,6 +442,7 @@ extension DocumentCaptureViewController {
timeoutTimer?.invalidate()
cameraSession.stopSession()
scanner.reset()
feedbackGenerator = nil
}

func handleTimeout(documentSide: DocumentSide) {
Expand Down Expand Up @@ -457,8 +478,8 @@ extension DocumentCaptureViewController {
method: .autoCapture
)

stopScanning()
state = .scanned(documentSide, uiImage)
stopScanning()
}

func saveOrFlipDocument(scannedImage image: UIImage, documentSide: DocumentSide) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
//
// AnimatedBorderView.swift
// StripeIdentity
//
// Created by Mel Ludowise on 2/19/22.
//

import Foundation
import UIKit

final class AnimatedBorderView: UIView {

struct Constants {
/// Animation speed in revolutions per second
static let animationSpeed: Double = 0.66
static let animationKey = "spin"
}

#if DEBUG
/// Disables animation. This should be only be modified for snapshot tests.
static var isAnimationEnabled = true
#endif

struct ViewModel {
let color1: UIColor
let color2: UIColor
let borderWidth: CGFloat
let cornerRadius: CGFloat
let isAnimating: Bool
}

// MARK: Instance Properties

private var gradientLayer: CAGradientLayer = {
let layer = CAGradientLayer()
// Note: This view is only used on iOS 13+
if #available(iOS 12.0, *) {
layer.type = .conic
}
layer.startPoint = CGPoint(x: 0.5, y: 0.5)
layer.endPoint = CGPoint(x: 1, y: 1)

// Initialize with dummy color until view has been configured
layer.colors = Array(repeating: UIColor.clear.cgColor, count: 4)

layer.locations = [
0,
0.12,
0.55,
0.75,
1
]
return layer
}()

private let maskLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.fillRule = .evenOdd
return layer
}()

private var borderWidth: CGFloat = 0
var isAnimating = false {
didSet {
guard oldValue != isAnimating else {
return
}

if isAnimating {
startAnimating()
} else {
stopAnimating()
}
}
}

init() {
super.init(frame: .zero)
layer.addSublayer(gradientLayer)
layer.mask = maskLayer
clipsToBounds = true
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func configure(with viewModel: ViewModel) {
gradientLayer.colors = [
viewModel.color1.cgColor,
viewModel.color2.withAlphaComponent(0).cgColor,
viewModel.color2.withAlphaComponent(0).cgColor,
viewModel.color1.cgColor,
viewModel.color1.cgColor,
]
backgroundColor = viewModel.color2
layer.cornerRadius = viewModel.cornerRadius
borderWidth = viewModel.borderWidth
updateLayerBounds()
isAnimating = viewModel.isAnimating
}

override func layoutSubviews() {
super.layoutSubviews()
updateLayerBounds()
}

override func willMove(toWindow newWindow: UIWindow?) {
super.willMove(toWindow: newWindow)

if let window = window {
gradientLayer.shouldRasterize = true
gradientLayer.rasterizationScale = window.screen.scale
}

if isAnimating {
startAnimating()
} else {
stopAnimating()
}
}
}

private extension AnimatedBorderView {
private func updateLayerBounds() {
// Gradient layer needs to be a square with width >= the diagonal
// dimension of this view so there are no gaps during animation
let dimension = sqrt(bounds.width * bounds.width + bounds.height * bounds.height)

gradientLayer.frame = CGRect(
x: bounds.minX + (bounds.width - dimension) / 2,
y: bounds.minY + (bounds.height - dimension) / 2,
width: dimension,
height: dimension
)

// Update mask layer bounds
let cutoutRect = bounds.inset(by: UIEdgeInsets(top: borderWidth, left: borderWidth, bottom: borderWidth, right: borderWidth))
let cutoutPath = UIBezierPath(
roundedRect: cutoutRect,
cornerRadius: layer.cornerRadius - borderWidth
)
let path = UIBezierPath(roundedRect: bounds, cornerRadius: layer.cornerRadius)
path.append(cutoutPath)
maskLayer.path = path.cgPath
}

private func startAnimating() {
#if DEBUG
guard AnimatedBorderView.isAnimationEnabled else { return }
#endif

let rotatingAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotatingAnimation.byValue = 2 * Float.pi
rotatingAnimation.duration = 1 / Constants.animationSpeed
rotatingAnimation.isAdditive = true
rotatingAnimation.repeatCount = .infinity
gradientLayer.add(rotatingAnimation, forKey: Constants.animationKey)
}

private func stopAnimating() {
gradientLayer.removeAnimation(forKey: Constants.animationKey)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ final class CameraScanningView: UIView {
static let cutoutCornerRadius: CGFloat = 12
static let cutoutAspectRatio: CGFloat = 1.5 // 3:2
static let cutoutBorderWidth: CGFloat = 4
static let cutoutBorderColor = UIColor.white
static let cutoutBorderStaticColor = UIColor.white
static let cutoutBorderAnimatedColor1 = IdentityUI.stripeBlurple
static let cutoutBorderAnimatedColor2 = UIColor.white
static let cutoutHorizontalPadding: CGFloat = 16
}

enum ViewModel {
case blank
case videoPreview(CameraSessionProtocol)
case videoPreview(CameraSessionProtocol, animateBorder: Bool)
case scanned(UIImage)
}

Expand All @@ -69,16 +71,7 @@ final class CameraScanningView: UIView {
return view
}()

/// Border for cut out
private let cutoutBorderView: UIView = {
let view = UIView()
view.layer.borderColor = Styling.cutoutBorderColor.cgColor
view.layer.borderWidth = Styling.cutoutBorderWidth
view.layer.cornerRadius = Styling.cutoutCornerRadius
view.backgroundColor = nil
return view
}()

private let cutoutBorderView = AnimatedBorderView()
private let cameraPreviewView = CameraPreviewView()

private let imageView: UIImageView = {
Expand Down Expand Up @@ -144,18 +137,27 @@ final class CameraScanningView: UIView {

switch viewModel {
case .blank:
cutoutBorderView.isAnimating = false
break

case .scanned(let image):
imageView.isHidden = false
imageView.image = image
scannedOverlayIconView.isHidden = false
cutoutBorderView.isAnimating = false

case .videoPreview(let cameraSession):
case .videoPreview(let cameraSession, let shouldAnimateBorder):
cameraPreviewView.isHidden = false
cameraPreviewView.session = cameraSession
cutoutOverlayView.isHidden = false
cutoutBorderView.isHidden = false
cutoutBorderView.configure(with: .init(
color1: shouldAnimateBorder ? Styling.cutoutBorderAnimatedColor1 : Styling.cutoutBorderStaticColor,
color2: shouldAnimateBorder ? Styling.cutoutBorderAnimatedColor2 : Styling.cutoutBorderStaticColor,
borderWidth: Styling.cutoutBorderWidth,
cornerRadius: Styling.cutoutCornerRadius,
isAnimating: shouldAnimateBorder
))
}
}

Expand Down
Loading

0 comments on commit 2050bda

Please sign in to comment.