diff --git a/Kickstarter-iOS/Views/AmountInputView.swift b/Kickstarter-iOS/Views/AmountInputView.swift index d0d00e3425..998a720555 100644 --- a/Kickstarter-iOS/Views/AmountInputView.swift +++ b/Kickstarter-iOS/Views/AmountInputView.swift @@ -2,11 +2,39 @@ import Library import Prelude import UIKit +private enum Layout { + enum Button { + static let height: CGFloat = 34 + } + + enum Toolbar { + static let height: CGFloat = 54 + } +} + class AmountInputView: UIView { // MARK: - Properties + private(set) lazy var doneButton: UIButton = { + UIButton(frame: .zero) + |> \.translatesAutoresizingMaskIntoConstraints .~ false + }() + private(set) lazy var label: UILabel = { UILabel(frame: .zero) }() - private(set) lazy var textField: UITextField = { UITextField(frame: .zero) }() + private(set) lazy var textField: UITextField = { + UITextField(frame: .zero) + |> \.inputAccessoryView .~ self.toolbar + }() + + private lazy var toolbar: UIToolbar = { + UIToolbar(frame: .zero) + |> \.items .~ [ + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + UIBarButtonItem(customView: self.doneButton) + ] + |> \.translatesAutoresizingMaskIntoConstraints .~ false + }() + private lazy var stackView: UIStackView = { UIStackView(frame: .zero) }() private var labelCenterYAnchor: NSLayoutConstraint? @@ -21,6 +49,13 @@ class AmountInputView: UIView { _ = ([self.label, self.textField], self.stackView) |> ksr_addArrangedSubviewsToStackView() + + self.toolbar.sizeToFit() + + NSLayoutConstraint.activate([ + self.doneButton.heightAnchor.constraint(equalToConstant: Layout.Button.height), + self.toolbar.heightAnchor.constraint(equalToConstant: Layout.Toolbar.height) + ]) } required init?(coder _: NSCoder) { @@ -36,12 +71,19 @@ class AmountInputView: UIView { |> checkoutWhiteBackgroundStyle |> checkoutRoundedCornersStyle + _ = self.doneButton + |> keyboardDoneButtonStyle + |> UIButton.lens.title(for: .normal) %~ { _ in Strings.Done() } + _ = self.label |> labelStyle _ = self.textField |> textFieldStyle + _ = self.toolbar + |> keyboardToolbarStyle + let isAccessibilityCategory = self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory _ = self.stackView diff --git a/Kickstarter-iOS/Views/Cells/PledgeAmountCell.swift b/Kickstarter-iOS/Views/Cells/PledgeAmountCell.swift index 674cfac338..0002d44635 100644 --- a/Kickstarter-iOS/Views/Cells/PledgeAmountCell.swift +++ b/Kickstarter-iOS/Views/Cells/PledgeAmountCell.swift @@ -40,6 +40,18 @@ final class PledgeAmountCell: UITableViewCell, ValueCell { self.spacer.widthAnchor.constraint(greaterThanOrEqualToConstant: Styles.grid(3)).isActive = true + self.amountInputView.doneButton.addTarget( + self, + action: #selector(PledgeAmountCell.doneButtonTapped(_:)), + for: .touchUpInside + ) + + self.amountInputView.textField.addTarget( + self, + action: #selector(PledgeAmountCell.textFieldDidChange(_:)), + for: .editingChanged + ) + self.stepper.addTarget( self, action: #selector(PledgeAmountCell.stepperValueChanged(_:)), @@ -84,11 +96,13 @@ final class PledgeAmountCell: UITableViewCell, ValueCell { override func bindViewModel() { super.bindViewModel() + self.amountInputView.doneButton.rac.enabled = self.viewModel.outputs.doneButtonIsEnabled self.amountInputView.label.rac.text = self.viewModel.outputs.currency + self.amountInputView.textField.rac.isFirstResponder = self.viewModel.outputs.textFieldIsFirstResponder self.amountInputView.textField.rac.text = self.viewModel.outputs.amount self.stepper.rac.maximumValue = self.viewModel.outputs.stepperMaxValue self.stepper.rac.minimumValue = self.viewModel.outputs.stepperMinValue - self.stepper.rac.value = self.viewModel.outputs.stepperInitialValue + self.stepper.rac.value = self.viewModel.outputs.stepperValue self.viewModel.outputs.generateSelectionFeedback .observeForUI() @@ -107,9 +121,17 @@ final class PledgeAmountCell: UITableViewCell, ValueCell { // MARK: - Actions + @objc func doneButtonTapped(_: UIButton) { + self.viewModel.inputs.doneButtonTapped() + } + @objc func stepperValueChanged(_ stepper: UIStepper) { self.viewModel.inputs.stepperValueChanged(stepper.value) } + + @objc func textFieldDidChange(_ textField: UITextField) { + self.viewModel.inputs.textFieldValueChanged(textField.text) + } } // MARK: - Styles diff --git a/Kickstarter-iOS/Views/Controllers/PledgeTableViewController.swift b/Kickstarter-iOS/Views/Controllers/PledgeTableViewController.swift index 4af8ddf5c8..35b04ca788 100644 --- a/Kickstarter-iOS/Views/Controllers/PledgeTableViewController.swift +++ b/Kickstarter-iOS/Views/Controllers/PledgeTableViewController.swift @@ -33,6 +33,11 @@ class PledgeTableViewController: UITableViewController { self.tableView.registerCellClass(PledgeShippingLocationCell.self) self.tableView.registerHeaderFooterClass(PledgeFooterView.self) + // Rebase Rebase Rebase + self.tableView.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(PledgeTableViewController.dismissKeyboard)) + ) + self.viewModel.inputs.viewDidLoad() } @@ -58,6 +63,12 @@ class PledgeTableViewController: UITableViewController { } } + // MARK: - Actions + + @objc func dismissKeyboard() { + self.tableView.endEditing(true) + } + // MARK: - UITableViewDelegate override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { diff --git a/Library/Styles/BaseStyles.swift b/Library/Styles/BaseStyles.swift index 3f722fb106..003c575311 100644 --- a/Library/Styles/BaseStyles.swift +++ b/Library/Styles/BaseStyles.swift @@ -26,6 +26,7 @@ public typealias SwitchControlStyle = (UISwitch) -> UISwitch public typealias TableViewStyle = (UITableView) -> UITableView public typealias TextFieldStyle = (UITextField) -> UITextField public typealias TextViewStyle = (UITextView) -> UITextView +public typealias ToolbarStyle = (UIToolbar) -> UIToolbar public typealias ViewStyle = (UIView) -> UIView public func baseControllerStyle() -> ((VC) -> VC) { @@ -149,3 +150,19 @@ private let baseNavigationBarStyle = <> UINavigationBar.lens.isTranslucent .~ false <> UINavigationBar.lens.barTintColor .~ .white <> UINavigationBar.lens.tintColor .~ .ksr_green_700 + +public let keyboardToolbarStyle: ToolbarStyle = { toolbar -> UIToolbar in + toolbar + |> roundedStyle(cornerRadius: 8) + |> \.layer.backgroundColor .~ UIColor.white.cgColor + |> \.layer.maskedCorners .~ [.layerMaxXMinYCorner, .layerMinXMinYCorner] +} + +public let keyboardDoneButtonStyle: ButtonStyle = { button -> UIButton in + button + |> greenButtonStyle + |> roundedStyle(cornerRadius: 6) + |> UIButton.lens.contentEdgeInsets .~ UIEdgeInsets(topBottom: Styles.grid(1), leftRight: Styles.grid(2)) + |> UIButton.lens.layer.borderWidth .~ 0 + |> UIButton.lens.titleLabel.font .~ UIFont.boldSystemFont(ofSize: 17) // Disables Dynamic Type support +} diff --git a/Library/ViewModels/PledgeAmountCellViewModel.swift b/Library/ViewModels/PledgeAmountCellViewModel.swift index 07a981cc70..0477c9de91 100644 --- a/Library/ViewModels/PledgeAmountCellViewModel.swift +++ b/Library/ViewModels/PledgeAmountCellViewModel.swift @@ -6,17 +6,21 @@ import ReactiveSwift public protocol PledgeAmountCellViewModelInputs { func configureWith(project: Project, reward: Reward) + func doneButtonTapped() func stepperValueChanged(_ value: Double) + func textFieldValueChanged(_ value: String?) } public protocol PledgeAmountCellViewModelOutputs { var amount: Signal { get } var currency: Signal { get } + var doneButtonIsEnabled: Signal { get } var generateSelectionFeedback: Signal { get } var generateNotificationWarningFeedback: Signal { get } - var stepperInitialValue: Signal { get } var stepperMaxValue: Signal { get } var stepperMinValue: Signal { get } + var stepperValue: Signal { get } + var textFieldIsFirstResponder: Signal { get } } public protocol PledgeAmountCellViewModelType { @@ -38,13 +42,13 @@ public final class PledgeAmountCellViewModel: PledgeAmountCellViewModelType, let initialValue = Signal.combineLatest(project, reward) .map { _ in 15.0 } - self.stepperInitialValue = initialValue - - self.amount = Signal.merge( + let stepperValue = Signal.merge( initialValue, self.stepperValueProperty.signal ) - .map { String(format: "%.0f", $0) } + + self.amount = stepperValue + .map { String(format: "%.0f", $0) } self.currency = project .map { currencySymbol(forCountry: $0.country).trimmed() } @@ -71,6 +75,36 @@ public final class PledgeAmountCellViewModel: PledgeAmountCellViewModelType, self.generateNotificationWarningFeedback = stepperValueChanged .filter { min, max, value in value <= min || max <= value } .ignoreValues() + + let textFieldValue = self.textFieldValueProperty.signal + .skipNil() + .map(Double.init) + .skipNil() + + self.doneButtonIsEnabled = Signal.combineLatest( + self.stepperMinValue, + self.stepperMaxValue, + Signal.merge( + stepperValue, + textFieldValue.signal + ) + ) + .map { min, max, doubleValue in min <= doubleValue && doubleValue <= max } + + let clampedStepperValue = Signal.combineLatest( + self.stepperMinValue, + self.stepperMaxValue, + textFieldValue.signal + ) + .map { minValue, maxValue, value in min(max(minValue, value), maxValue) } + + self.stepperValue = Signal.merge( + initialValue, + clampedStepperValue + ) + + self.textFieldIsFirstResponder = self.doneButtonTappedProperty.signal + .mapConst(false) } private let projectAndRewardProperty = MutableProperty<(Project, Reward)?>(nil) @@ -78,18 +112,30 @@ public final class PledgeAmountCellViewModel: PledgeAmountCellViewModelType, self.projectAndRewardProperty.value = (project, reward) } + private let doneButtonTappedProperty = MutableProperty(()) + public func doneButtonTapped() { + self.doneButtonTappedProperty.value = () + } + private let stepperValueProperty = MutableProperty(0) public func stepperValueChanged(_ value: Double) { self.stepperValueProperty.value = value } + private let textFieldValueProperty = MutableProperty(nil) + public func textFieldValueChanged(_ value: String?) { + self.textFieldValueProperty.value = value + } + public let amount: Signal public let currency: Signal + public let doneButtonIsEnabled: Signal public let generateSelectionFeedback: Signal public let generateNotificationWarningFeedback: Signal - public let stepperInitialValue: Signal public let stepperMaxValue: Signal public let stepperMinValue: Signal + public let stepperValue: Signal + public let textFieldIsFirstResponder: Signal public var inputs: PledgeAmountCellViewModelInputs { return self } public var outputs: PledgeAmountCellViewModelOutputs { return self } diff --git a/Library/ViewModels/PledgeAmountCellViewModelTests.swift b/Library/ViewModels/PledgeAmountCellViewModelTests.swift index 904f034695..99049a3cbe 100644 --- a/Library/ViewModels/PledgeAmountCellViewModelTests.swift +++ b/Library/ViewModels/PledgeAmountCellViewModelTests.swift @@ -9,24 +9,28 @@ internal final class PledgeAmountCellViewModelTests: TestCase { private let amount = TestObserver() private let currency = TestObserver() + private let doneButtonIsEnabled = TestObserver() private let generateSelectionFeedback = TestObserver() private let generateNotificationWarningFeedback = TestObserver() - private let stepperInitialValue = TestObserver() private let stepperMinValue = TestObserver() private let stepperMaxValue = TestObserver() + private let stepperValue = TestObserver() + private let textFieldIsFirstResponder = TestObserver() override func setUp() { super.setUp() self.vm.outputs.amount.observe(self.amount.observer) self.vm.outputs.currency.observe(self.currency.observer) + self.vm.outputs.doneButtonIsEnabled.observe(self.doneButtonIsEnabled.observer) self.vm.outputs.generateSelectionFeedback.observe(self.generateSelectionFeedback.observer) self.vm.outputs.generateNotificationWarningFeedback.observe( self.generateNotificationWarningFeedback.observer ) - self.vm.outputs.stepperInitialValue.observe(self.stepperInitialValue.observer) + self.vm.outputs.stepperValue.observe(self.stepperValue.observer) self.vm.outputs.stepperMinValue.observe(self.stepperMinValue.observer) self.vm.outputs.stepperMaxValue.observe(self.stepperMaxValue.observer) + self.vm.outputs.textFieldIsFirstResponder.observe(self.textFieldIsFirstResponder.observer) } func testAmountAndCurrency() { @@ -36,6 +40,69 @@ internal final class PledgeAmountCellViewModelTests: TestCase { self.currency.assertValues(["$"]) } + func testDoneButtonIsEnabled_Stepper() { + self.vm.inputs.configureWith(project: .template, reward: .template) + + self.doneButtonIsEnabled.assertValues([true]) + + self.vm.inputs.stepperValueChanged(16) + self.doneButtonIsEnabled.assertValues([true, true]) + + self.vm.inputs.stepperValueChanged(200) + self.doneButtonIsEnabled.assertValues([true, true, false]) + + self.vm.inputs.stepperValueChanged(20) + self.doneButtonIsEnabled.assertValues([true, true, false, true]) + + self.vm.inputs.stepperValueChanged(1) + self.doneButtonIsEnabled.assertValues([true, true, false, true, false]) + + self.vm.inputs.stepperValueChanged(10) + self.doneButtonIsEnabled.assertValues([true, true, false, true, false, true]) + } + + func testDoneButtonIsEnabled_TextField() { + self.vm.inputs.configureWith(project: .template, reward: .template) + + self.doneButtonIsEnabled.assertValues([true]) + + self.vm.inputs.textFieldValueChanged("16") + self.doneButtonIsEnabled.assertValues([true, true]) + + self.vm.inputs.textFieldValueChanged("200") + self.doneButtonIsEnabled.assertValues([true, true, false]) + + self.vm.inputs.textFieldValueChanged("20") + self.doneButtonIsEnabled.assertValues([true, true, false, true]) + + self.vm.inputs.textFieldValueChanged("1") + self.doneButtonIsEnabled.assertValues([true, true, false, true, false]) + + self.vm.inputs.textFieldValueChanged("10") + self.doneButtonIsEnabled.assertValues([true, true, false, true, false, true]) + } + + func testDoneButtonIsEnabled_Combined() { + self.vm.inputs.configureWith(project: .template, reward: .template) + + self.doneButtonIsEnabled.assertValues([true]) + + self.vm.inputs.textFieldValueChanged("16") + self.doneButtonIsEnabled.assertValues([true, true]) + + self.vm.inputs.stepperValueChanged(200) + self.doneButtonIsEnabled.assertValues([true, true, false]) + + self.vm.inputs.textFieldValueChanged("20") + self.doneButtonIsEnabled.assertValues([true, true, false, true]) + + self.vm.inputs.stepperValueChanged(1) + self.doneButtonIsEnabled.assertValues([true, true, false, true, false]) + + self.vm.inputs.textFieldValueChanged("10") + self.doneButtonIsEnabled.assertValues([true, true, false, true, false, true]) + } + func testGenerateSelectionFeedback() { self.vm.inputs.configureWith(project: .template, reward: .template) @@ -76,12 +143,6 @@ internal final class PledgeAmountCellViewModelTests: TestCase { self.generateNotificationWarningFeedback.assertValueCount(2) } - func testStepperInitialValue() { - self.vm.inputs.configureWith(project: .template, reward: .template) - - self.stepperInitialValue.assertValue(15) - } - func testStepperMinValue() { self.vm.inputs.configureWith(project: .template, reward: .template) @@ -93,4 +154,37 @@ internal final class PledgeAmountCellViewModelTests: TestCase { self.stepperMaxValue.assertValue(20) } + + func testStepperValue() { + self.vm.inputs.configureWith(project: .template, reward: .template) + + self.stepperValue.assertValue(15) + } + + func testStepperValueChangesWithTextFieldInput() { + self.vm.inputs.configureWith(project: .template, reward: .template) + + self.stepperValue.assertValue(15) + + self.vm.inputs.textFieldValueChanged("11") + self.stepperValue.assertValues([15, 11]) + + self.vm.inputs.textFieldValueChanged("16") + self.stepperValue.assertValues([15, 11, 16]) + + self.vm.inputs.textFieldValueChanged("100") + self.stepperValue.assertValues([15, 11, 16, 20]) + + self.vm.inputs.textFieldValueChanged("1") + self.stepperValue.assertValues([15, 11, 16, 20, 10]) + + self.vm.inputs.textFieldValueChanged("11") + self.stepperValue.assertValues([15, 11, 16, 20, 10, 11]) + } + + func testTextFieldIsFirstResponder() { + self.vm.inputs.doneButtonTapped() + + self.textFieldIsFirstResponder.assertValue(false) + } }