Skip to content

Commit

Permalink
Add consent checkbox to SelfieScanningView (#1052)
Browse files Browse the repository at this point in the history
- Refactors `CheckboxButton` to:
  - Accept attributed text
  - Update the text value outside of init
  - Open URLs from the attributed text inside of a TextView
- Adds html-based consent text to a consent checkbox
  • Loading branch information
mludowise-stripe authored Apr 29, 2022
1 parent d97ebed commit 5bf3d5c
Show file tree
Hide file tree
Showing 22 changed files with 196 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@ final class SelfieScanningView: UIView {
static let scannedImageSize = CGSize(width: 172, height: 198)
static let scannedImageSpacing: CGFloat = 12
static let scannedImageCornerRadius: CGFloat = 12

static let consentTopPadding: CGFloat = 36
static let consentTextStyle = UIFont.TextStyle.footnote
static var consentHTMLStyle: HTMLStyle {
let boldFont = IdentityUI.preferredFont(forTextStyle: consentTextStyle, weight: .bold)
return .init(
bodyFont: IdentityUI.preferredFont(forTextStyle: consentTextStyle),
h1Font: boldFont,
h2Font: boldFont,
h3Font: boldFont,
h4Font: boldFont,
h5Font: boldFont,
h6Font: boldFont,
isLinkUnderlined: false
)
}

static var consentCheckboxTheme: ElementsUITheme {
var theme = ElementsUITheme.default
theme.colors.bodyText = IdentityUI.textColor
theme.colors.secondaryText = IdentityUI.textColor
return theme
}
}

struct ViewModel {
Expand All @@ -38,7 +61,7 @@ final class SelfieScanningView: UIView {
/// Live video feed from the camera while taking selfies
case videoPreview(CameraSessionProtocol)
/// Display scanned selfie images
case scanned([UIImage])
case scanned([UIImage], consentHTMLText: String, consentHandler: (Bool) -> Void, openURLHandler: (URL) -> Void)
}

let state: State
Expand All @@ -53,9 +76,7 @@ final class SelfieScanningView: UIView {
}
}

// MARK: Views

private let instructionLabelView = BottomAlignedLabel()
// MARK: - Properties

private let vStack: UIStackView = {
let stackView = UIStackView()
Expand All @@ -65,11 +86,17 @@ final class SelfieScanningView: UIView {
return stackView
}()

// MARK: Instructions
private let instructionLabelView = BottomAlignedLabel()

// MARK: Camera Preview
private let previewContainerView = CameraPreviewContainerView()

/// Camera preview
private let cameraPreviewView = CameraPreviewView()

// MARK: Scanned Images

/// Horizontal stack view of scanned images
private let scannedImageHStack: UIStackView = {
let stackView = UIStackView()
Expand All @@ -80,6 +107,25 @@ final class SelfieScanningView: UIView {

private let scannedImageScrollView = UIScrollView()

// MARK: Consent

private lazy var consentCheckboxButton: CheckboxButton = {
let checkbox = CheckboxButton(theme: Styling.consentCheckboxTheme)
checkbox.isSelected = false
checkbox.addTarget(self, action: #selector(didToggleConsent), for: .touchUpInside)
checkbox.delegate = self
return checkbox
}()

/// Called when the user taps the consent checkbox
private var consentHandler: ((Bool) -> Void)?

/// Called when the user taps on a link in the consent text
private var openURLHandler: ((URL) -> Void)?

/// Cache of the consent text from the viewModel so we can rebuild the
/// attributed string when font traits change
private var consentHTMLText: String?

// MARK: Init

Expand Down Expand Up @@ -108,6 +154,7 @@ final class SelfieScanningView: UIView {
cameraPreviewView.isHidden = true
previewContainerView.isHidden = true
scannedImageScrollView.isHidden = true
consentCheckboxButton.isHidden = true

switch viewModel.state {
case .blank:
Expand All @@ -118,9 +165,47 @@ final class SelfieScanningView: UIView {
cameraPreviewView.isHidden = false
cameraPreviewView.session = cameraSession

case .scanned(let images):
case .scanned(let images, let consentText, let consentHandler, let openURLHandler):
scannedImageScrollView.isHidden = false
rebuildImageHStack(with: images)

do {
consentCheckboxButton.setAttributedText(try NSAttributedString(
htmlText: consentText,
style: Styling.consentHTMLStyle
))

consentCheckboxButton.isHidden = false
self.consentHandler = consentHandler
self.openURLHandler = openURLHandler
self.consentHTMLText = consentText
} catch {
// TODO(mludowise|IDPROD-2816): Log error if consent can't be rendered.
// Keep the consent checkbox hidden and treat this case the same
// as if the user did not give consent.
}
}
}

// MARK: UIView

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)

// NOTE: `traitCollectionDidChange` is called off the main thread when the app backgrounds
DispatchQueue.main.async { [weak self] in
guard let consentHTMLText = self?.consentHTMLText else { return }
do {
// Recompute attributed text with updated font sizes
self?.consentCheckboxButton.setAttributedText(try NSAttributedString(
htmlText: consentHTMLText,
style: Styling.consentHTMLStyle
))
} catch {
// Ignore errors thrown. This means the font size won't update,
// but the text should still display if an error wasn't already
// thrown from `configure`.
}
}
}
}
Expand All @@ -132,6 +217,7 @@ private extension SelfieScanningView {
vStack.addArrangedSubview(instructionLabelView)
vStack.addArrangedSubview(previewContainerView)
vStack.addArrangedSubview(scannedImageScrollView)
vStack.addArrangedSubview(consentCheckboxButton)

previewContainerView.contentView.addAndPinSubview(cameraPreviewView)

Expand All @@ -142,6 +228,8 @@ private extension SelfieScanningView {
scannedImageHStack.translatesAutoresizingMaskIntoConstraints = false
scannedImageScrollView.setContentHuggingPriority(.required, for: .horizontal)

vStack.setCustomSpacing(Styling.consentTopPadding, after: scannedImageScrollView)

NSLayoutConstraint.activate([
// Set the container to the same height as the document scanning preview, but as a square
widthAnchor.constraint(
Expand Down Expand Up @@ -189,4 +277,16 @@ private extension SelfieScanningView {

NSLayoutConstraint.activate(constraints)
}

@objc func didToggleConsent() {
consentHandler?(consentCheckboxButton.isSelected)
}
}

// MARK: - CheckboxButton
extension SelfieScanningView: CheckboxButtonDelegate {
func checkboxButton(_ checkboxButton: CheckboxButton, shouldOpen url: URL) -> Bool {
openURLHandler?(url)
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import FBSnapshotTestCase

final class SelfieScanningViewSnapshotTest: FBSnapshotTestCase {
let mockText = "A long line of text that should wrap to multiple lines"
let consentText = "Allow Stripe to use your images to improve our biometric verification technology. You can remove Stripe's permissions at any time by contacting Stripe. <a href='https://stripe.com'>Learn how Stripe uses data</a>"

let view = SelfieScanningView()
let mockImage = CapturedImageMock.frontDriversLicense.image
Expand Down Expand Up @@ -44,14 +45,22 @@ final class SelfieScanningViewSnapshotTest: FBSnapshotTestCase {

func testMultipleScannedImages() {
verifyView(with: .init(
state: .scanned(Array(repeating: mockImage, count: 3)),
state: .scanned(Array(repeating: mockImage, count: 3),
consentHTMLText: consentText,
consentHandler: {_ in },
openURLHandler: {_ in }
),
instructionalText: mockText
))
}

func testOneScannedImage() {
verifyView(with: .init(
state: .scanned([mockImage]),
state: .scanned([mockImage],
consentHTMLText: consentText,
consentHandler: {_ in },
openURLHandler: {_ in }
),
instructionalText: mockText
))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@
import UIKit
@_spi(STP) import StripeCore

@_spi(STP) public protocol CheckboxButtonDelegate: AnyObject {
/// Return `true` to open the URL in the device's default browser.
/// Return `false` to custom handle the URL.
func checkboxButton(_ checkboxButton: CheckboxButton, shouldOpen url: URL) -> Bool
}

/// For internal SDK use only
@objc(STP_Internal_CheckboxButton)
@_spi(STP) public class CheckboxButton: UIControl {
// MARK: - Properties

public weak var delegate: CheckboxButtonDelegate?

private var font: UIFont {
return theme.fonts.checkbox
}
Expand All @@ -22,11 +30,17 @@ import UIKit
return theme.fonts.checkboxEmphasis
}

private lazy var label: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.isAccessibilityElement = false
return label
private lazy var textView: UITextView = {
let textView = UITextView()
textView.isEditable = false
textView.isSelectable = false
textView.isScrollEnabled = false
textView.backgroundColor = nil
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.adjustsFontForContentSizeCategory = true
textView.delegate = self
return textView
}()

private lazy var descriptionLabel: UILabel = {
Expand All @@ -47,7 +61,7 @@ import UIKit
}()

private lazy var stackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [label, descriptionLabel])
let stackView = UIStackView(arrangedSubviews: [textView, descriptionLabel])
stackView.spacing = 4
stackView.axis = .vertical
stackView.distribution = .equalSpacing
Expand All @@ -59,7 +73,7 @@ import UIKit
/// Aligns the checkbox vertically to the first baseline of `label`.
private lazy var checkboxAlignmentConstraint: NSLayoutConstraint = {
return checkbox.centerYAnchor.constraint(
equalTo: label.firstBaselineAnchor,
equalTo: textView.firstBaselineAnchor,
constant: 0
)
}()
Expand All @@ -78,7 +92,7 @@ import UIKit
public override var isEnabled: Bool {
didSet {
checkbox.isUserInteractionEnabled = isEnabled
label.isUserInteractionEnabled = isEnabled
textView.isUserInteractionEnabled = isEnabled
}
}

Expand All @@ -95,25 +109,31 @@ import UIKit

// MARK: - Initializers

public init(text: String, description: String? = nil, theme: ElementsUITheme = .default) {
public init(description: String? = nil, theme: ElementsUITheme = .default) {
self.theme = theme
super.init(frame: .zero)

isAccessibilityElement = true
accessibilityLabel = text
accessibilityHint = description
accessibilityTraits = UISwitch().accessibilityTraits

label.text = text
descriptionLabel.text = description

setupUI()

let didTapGestureRecognizer = UITapGestureRecognizer(
target: self, action: #selector(didTap))
addGestureRecognizer(didTapGestureRecognizer)
}

updateLabels()
public convenience init(text: String, description: String? = nil, theme: ElementsUITheme = .default) {
self.init(description: description, theme: theme)
setText(text)
}

public convenience init(attributedText: NSAttributedString, description: String? = nil, theme: ElementsUITheme = .default) {
self.init(description: description, theme: theme)
setAttributedText(attributedText)
}

required init?(coder: NSCoder) {
Expand All @@ -124,7 +144,6 @@ import UIKit
super.layoutSubviews()

// Preferred max width sometimes is off when changing font size
label.preferredMaxLayoutWidth = stackView.bounds.width
descriptionLabel.preferredMaxLayoutWidth = stackView.bounds.width
}

Expand All @@ -133,6 +152,18 @@ import UIKit
updateLabels()
}

public func setText(_ text: String) {
accessibilityLabel = text
textView.text = text
updateLabels()
}

public func setAttributedText(_ attributedText: NSAttributedString) {
accessibilityLabel = attributedText.string
textView.attributedText = attributedText
updateLabels()
}

private func setupUI() {
addSubview(checkbox)
addSubview(stackView)
Expand Down Expand Up @@ -162,21 +193,29 @@ import UIKit
private func updateLabels() {
let hasDescription = descriptionLabel.text != nil

label.font = hasDescription ? emphasisFont : font
label.textColor = hasDescription ? theme.colors.bodyText : theme.colors.secondaryText
let textFont = hasDescription ? emphasisFont : font
textView.font = textFont
textView.textColor = hasDescription ? theme.colors.bodyText : theme.colors.secondaryText

descriptionLabel.font = font
descriptionLabel.isHidden = !hasDescription
descriptionLabel.textColor = theme.colors.secondaryText

// Align checkbox to center of first line of text. The center of the checkbox is already
// pinned to the first baseline via a constraint, so we just need to calculate
// the offset from baseline to line center, and apply the offset to the contraint.
let baselineToLineCenterOffset = (label.font.ascender + label.font.descender) / 2
// the offset from baseline to line center, and apply the offset to the constraint.
let baselineToLineCenterOffset = (textFont.ascender + textFont.descender) / 2
checkboxAlignmentConstraint.constant = -baselineToLineCenterOffset
}
}

// MARK: - UITextViewDelegate
extension CheckboxButton: UITextViewDelegate {
public func textView(_ textView: UITextView, shouldInteractWith url: URL, in characterRange: NSRange) -> Bool {
return delegate?.checkboxButton(self, shouldOpen: url) ?? true
}
}

// MARK: - CheckBox
/// For internal SDK use only
@objc(STP_Internal_CheckBox)
Expand Down
Loading

0 comments on commit 5bf3d5c

Please sign in to comment.