Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NT-394] - Loading Button #892

Merged
merged 18 commits into from
Oct 18, 2019
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Kickstarter-iOS/Views/Controllers/PledgeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ final class PledgeViewController: UIViewController, MessageBannerViewControllerP
|> \.delegate .~ self
}()

private lazy var submitButton: UIButton = { UIButton(type: .custom) }()
private lazy var submitButton: LoadingButton = { LoadingButton(type: .custom) }()

private lazy var summarySectionViews = {
[
Expand Down Expand Up @@ -322,6 +322,12 @@ final class PledgeViewController: UIViewController, MessageBannerViewControllerP
|> \.title %~ { _ in title }
}

self.viewModel.outputs.submitButtonIsLoading
.observeForUI()
.observeValues { [weak self] isLoading in
self?.submitButton.isLoading = isLoading
}

// MARK: Errors

self.viewModel.outputs.createBackingError
Expand Down
159 changes: 159 additions & 0 deletions Kickstarter-iOS/Views/LoadingButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import Library
import Prelude
import UIKit

final class LoadingButton: UIButton {
// MARK: - Properties

private let viewModel: LoadingButtonViewModelType = LoadingButtonViewModel()

private lazy var activityIndicator: UIActivityIndicatorView = {
UIActivityIndicatorView(style: .white)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style is something that could potentially be injected in the initializer in the future but for now I think this style will suffice

|> \.hidesWhenStopped .~ true
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the magic prop that does not require us to add/remove it from the view hierarchy...it will automatically show/hide the component depending on the isAnimating property.

|> \.translatesAutoresizingMaskIntoConstraints .~ false
}()

public var isLoading: Bool = false {
didSet {
self.viewModel.inputs.isLoading(self.isLoading)
}
}

private var originalTitles: [UInt: String] = [:]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason you chose to do this without a view model?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I simply tried to go with the simplest approach but can back this by a VM to be more consistent with the rest of the codebase 👍


// MARK: - Lifecycle

override init(frame: CGRect) {
super.init(frame: frame)

self.configureViews()
self.bindViewModel()
}

required init?(coder: NSCoder) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Init with coder so that this supports XIBs

super.init(coder: coder)

self.configureViews()
self.bindViewModel()
}

override func setTitle(_ title: String?, for state: UIControl.State) {
// Do not allow changing the title while the activity indicator is animating
guard !self.activityIndicator.isAnimating else { return }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting a title while the activity indicator is loading would display both the indicator and the title so I think this is the right approach here..we can potentially add logic to stop animating if we're setting title but I think due to our RAC bindings it could cause some flickering


super.setTitle(title, for: state)
}

// MARK: - Configuration

private func configureViews() {
self.addSubview(self.activityIndicator)
self.activityIndicator.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.activityIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
}

// MARK: - View model

override func bindViewModel() {
super.bindViewModel()

self.viewModel.outputs.isUserInteractionEnabled
.observeForUI()
.observeValues { [weak self] isUserInteractionEnabled in
_ = self
?|> \.isUserInteractionEnabled .~ isUserInteractionEnabled
}

self.viewModel.outputs.startLoading
.observeForUI()
.observeValues { [weak self] in
self?.startLoading()
}

self.viewModel.outputs.stopLoading
.observeForUI()
.observeValues { [weak self] in
self?.stopLoading()
}
}

// MARK: - Loading

private func startLoading() {
self.removeTitle()

self.activityIndicator.startAnimating()
}

private func stopLoading() {
self.activityIndicator.stopAnimating()

self.restoreTitle()
}

// MARK: - Titles

private func removeTitle() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about something like this for DRYness:

  private func removeTitle() {
    let states: [UIControl.State] = [.disabled, .highlighted, .normal, .selected]

    states.compactMap { state -> (String, UIControl.State)? in
      guard let title = self.title(for: state) else { return nil }
      return (title, state)
    }
    .forEach { title, state in
      self.originalTitles[state.rawValue] = title
      self.setTitle(nil, for: state)
    }

    _ = self
      |> \.accessibilityLabel %~ { _ in Strings.Loading() }

    UIAccessibility.post(notification: .layoutChanged, argument: self)
  }

let disabledState = UIControl.State.disabled
let highlightedState = UIControl.State.highlighted
let normalState = UIControl.State.normal
let selectedState = UIControl.State.selected

if let disabledTitle = self.title(for: disabledState) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracting the title first will make sure that if for some reason we want to start the label with a loading state, we don't just clean the titles, but rather only cache previous titles if there were any.

self.originalTitles[disabledState.rawValue] = disabledTitle
self.setTitle(nil, for: disabledState)
}

if let highlightedTitle = self.title(for: highlightedState) {
self.originalTitles[highlightedState.rawValue] = highlightedTitle
self.setTitle(nil, for: highlightedState)
}

if let normalTitle = self.title(for: normalState) {
self.originalTitles[normalState.rawValue] = normalTitle
self.setTitle(nil, for: normalState)
}

if let selectedTitle = self.title(for: selectedState) {
self.originalTitles[selectedState.rawValue] = selectedTitle
self.setTitle(nil, for: selectedState)
}

_ = self
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sure that during the loading phase it's still accessible..

|> \.accessibilityLabel %~ { _ in Strings.Loading() }

UIAccessibility.post(notification: .layoutChanged, argument: self)
}

private func restoreTitle() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably do this here too.

let disabledState = UIControl.State.disabled
let highlightedState = UIControl.State.highlighted
let normalState = UIControl.State.normal
let selectedState = UIControl.State.selected

if let disabledTitle = self.originalTitles[disabledState.rawValue] {
self.setTitle(disabledTitle, for: disabledState)
self.originalTitles[disabledState.rawValue] = nil
}

if let highlightedTitle = self.originalTitles[highlightedState.rawValue] {
self.setTitle(highlightedTitle, for: highlightedState)
self.originalTitles[highlightedState.rawValue] = nil
}

if let normalTitle = self.originalTitles[normalState.rawValue] {
self.setTitle(normalTitle, for: normalState)
self.originalTitles[normalState.rawValue] = nil
}

if let selectedTitle = self.originalTitles[selectedState.rawValue] {
self.setTitle(selectedTitle, for: selectedState)
self.originalTitles[selectedState.rawValue] = nil
}

_ = self
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this ensures that after loading is done it will again use title for the accessibility label

|> \.accessibilityLabel .~ nil

UIAccessibility.post(notification: .layoutChanged, argument: self)
}
}
12 changes: 12 additions & 0 deletions Kickstarter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
37096C3522BC282E003D1F40 /* MockAppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37096C3122BC23AD003D1F40 /* MockAppEnvironment.swift */; };
370ACB00225D337900C8745F /* PledgeAmountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370ACAFF225D337900C8745F /* PledgeAmountViewController.swift */; };
370BE71622541C8100B44DB2 /* UIViewController+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370BE71522541C8100B44DB2 /* UIViewController+URL.swift */; };
370C8B64234FCC6F00DE75DD /* LoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370C8B63234FCC6F00DE75DD /* LoadingButton.swift */; };
370C8B6623590CA500DE75DD /* LoadingButtonViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370C8B6523590CA500DE75DD /* LoadingButtonViewModel.swift */; };
370C8B6823590CC200DE75DD /* LoadingButtonViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370C8B6723590CC200DE75DD /* LoadingButtonViewModelTests.swift */; };
370F527A2254267900F159B9 /* UIApplicationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370F52792254267900F159B9 /* UIApplicationType.swift */; };
370F52B3225426C700F159B9 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370F52B2225426C700F159B9 /* UIApplication.swift */; };
37280272226F880100E0ACBB /* CheckoutStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37280250226F878300E0ACBB /* CheckoutStyles.swift */; };
Expand Down Expand Up @@ -1472,6 +1475,9 @@
370ACAFF225D337900C8745F /* PledgeAmountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PledgeAmountViewController.swift; sourceTree = "<group>"; };
370BE71522541C8100B44DB2 /* UIViewController+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+URL.swift"; sourceTree = "<group>"; };
370BE74E22541C8F00B44DB2 /* UIViewController+URLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+URLTests.swift"; sourceTree = "<group>"; };
370C8B63234FCC6F00DE75DD /* LoadingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButton.swift; sourceTree = "<group>"; };
370C8B6523590CA500DE75DD /* LoadingButtonViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonViewModel.swift; sourceTree = "<group>"; };
370C8B6723590CC200DE75DD /* LoadingButtonViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonViewModelTests.swift; sourceTree = "<group>"; };
370F52792254267900F159B9 /* UIApplicationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationType.swift; sourceTree = "<group>"; };
370F52B2225426C700F159B9 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
37280250226F878300E0ACBB /* CheckoutStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutStyles.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2870,6 +2876,7 @@
595CDAB71D3537180051C816 /* FundingGraphView.swift */,
A7ED205B1E83240D00BFFA01 /* FundingGraphViewTests.swift */,
77FD8B45216D6245000A95AC /* LoadingBarButtonItemView.swift */,
370C8B63234FCC6F00DE75DD /* LoadingButton.swift */,
77BF99B622652C9500513CE3 /* MultiLineButton.swift */,
D63BBD382180BE5D007E01F0 /* PaymentMethodsFooterView.swift */,
D741577A2284849000C0B907 /* PledgeCTAContainerView.swift */,
Expand Down Expand Up @@ -3623,6 +3630,8 @@
A7ED1F8E1E831C5C00BFFA01 /* HelpViewModelTests.swift */,
D04AAC18218BB70B00CF713E /* LoadingBarButtonItemViewModel.swift */,
D04AAC14218BB70A00CF713E /* LoadingBarButtonItemViewModelTests.swift */,
370C8B6523590CA500DE75DD /* LoadingButtonViewModel.swift */,
370C8B6723590CC200DE75DD /* LoadingButtonViewModelTests.swift */,
A7F4419A1D005A9400FE6FC5 /* LoginToutViewModel.swift */,
A7ED1F761E831C5C00BFFA01 /* LoginToutViewModelTests.swift */,
A7698B291D00602800953FD3 /* LoginViewModel.swift */,
Expand Down Expand Up @@ -4752,6 +4761,7 @@
A7F441C51D005A9400FE6FC5 /* LoginToutViewModel.swift in Sources */,
D7E20EA7228B4B7D00BA61A0 /* PledgeCTAContainerViewViewModel.swift in Sources */,
A75C811E1D210C4700B5AD03 /* ProjectActivityItemProvider.swift in Sources */,
370C8B6623590CA500DE75DD /* LoadingButtonViewModel.swift in Sources */,
D79F0F7321067FB500D3B32C /* SettingsRecommendationsCellViewModel.swift in Sources */,
D04AAC29218BB70D00CF713E /* BetaToolsViewModel.swift in Sources */,
D093B49C21A86FD800910962 /* PushRegistration.swift in Sources */,
Expand Down Expand Up @@ -5100,6 +5110,7 @@
A7ED1F371E830FDC00BFFA01 /* String+WhitespaceTests.swift in Sources */,
A7ED1FC31E831C5C00BFFA01 /* ProfileViewModelTests.swift in Sources */,
A7ED20011E831C5C00BFFA01 /* SignupViewModelTests.swift in Sources */,
370C8B6823590CC200DE75DD /* LoadingButtonViewModelTests.swift in Sources */,
A7ED1FF71E831C5C00BFFA01 /* ResetPasswordViewModelTests.swift in Sources */,
778CCCDA2285BF8900FB8D35 /* UIView+AutoLayoutTests.swift in Sources */,
373AB25F222A0DAC00769FC2 /* PasswordValidationTests.swift in Sources */,
Expand Down Expand Up @@ -5197,6 +5208,7 @@
771E630B23425977005967E8 /* CancelPledgeViewController.swift in Sources */,
A7A0534E1CD19C68005AF5E2 /* CommentDialogViewController.swift in Sources */,
A75AB2231C8A85D1002FC3E6 /* ActivityUpdateCell.swift in Sources */,
370C8B64234FCC6F00DE75DD /* LoadingButton.swift in Sources */,
A75A292E1CE0B95300D35E5C /* MessagesDataSource.swift in Sources */,
A74382051D3458C900040A95 /* PaddingCell.swift in Sources */,
0169F9841D6E0B2000C8D5C5 /* DiscoveryFiltersStaticRowCell.swift in Sources */,
Expand Down
51 changes: 51 additions & 0 deletions Library/ViewModels/LoadingButtonViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Prelude
import ReactiveSwift

public protocol LoadingButtonViewModelInputs {
func isLoading(_ isLoading: Bool)
}

public protocol LoadingButtonViewModelOutputs {
var isUserInteractionEnabled: Signal<Bool, Never> { get }
var startLoading: Signal<Void, Never> { get }
var stopLoading: Signal<Void, Never> { get }
}

public protocol LoadingButtonViewModelType {
var inputs: LoadingButtonViewModelInputs { get }
var outputs: LoadingButtonViewModelOutputs { get }
}

public final class LoadingButtonViewModel:
LoadingButtonViewModelType,
LoadingButtonViewModelInputs,
LoadingButtonViewModelOutputs {
public init() {
let isLoading = self.isLoadingProperty.signal
.skipNil()
.skipRepeats()

self.isUserInteractionEnabled = isLoading
.negate()

self.startLoading = isLoading
.filter(isTrue)
.ignoreValues()

self.stopLoading = isLoading
.filter(isFalse)
.ignoreValues()
}

private let isLoadingProperty = MutableProperty<Bool?>(nil)
public func isLoading(_ isLoading: Bool) {
self.isLoadingProperty.value = isLoading
}

public let isUserInteractionEnabled: Signal<Bool, Never>
public let startLoading: Signal<Void, Never>
public let stopLoading: Signal<Void, Never>

public var inputs: LoadingButtonViewModelInputs { return self }
public var outputs: LoadingButtonViewModelOutputs { return self }
}
63 changes: 63 additions & 0 deletions Library/ViewModels/LoadingButtonViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
@testable import Library
import ReactiveExtensions
import ReactiveExtensions_TestHelpers
import ReactiveSwift
import XCTest

final class LoadingButtonViewModelTests: TestCase {
private let vm: LoadingButtonViewModelType = LoadingButtonViewModel()

private let isUserInteractionEnabled = TestObserver<Bool, Never>()
private let startLoading = TestObserver<Void, Never>()
private let stopLoading = TestObserver<Void, Never>()

override func setUp() {
super.setUp()

self.vm.outputs.isUserInteractionEnabled.observe(self.isUserInteractionEnabled.observer)
self.vm.outputs.startLoading.observe(self.startLoading.observer)
self.vm.outputs.stopLoading.observe(self.stopLoading.observer)
}

func testIsUserInteractionEnabled() {
self.vm.inputs.isLoading(false)
self.isUserInteractionEnabled.assertValues([true])

self.vm.inputs.isLoading(false)
self.isUserInteractionEnabled.assertValues([true])

self.vm.inputs.isLoading(true)
self.isUserInteractionEnabled.assertValues([true, false])

self.vm.inputs.isLoading(true)
self.isUserInteractionEnabled.assertValues([true, false])
}

func testStartLoading() {
self.vm.inputs.isLoading(true)
self.startLoading.assertValueCount(1)

self.vm.inputs.isLoading(true)
self.startLoading.assertValueCount(1)

self.vm.inputs.isLoading(false)
self.startLoading.assertValueCount(1)

self.vm.inputs.isLoading(true)
self.startLoading.assertValueCount(2)
}

func testStopLoading() {
self.vm.inputs.isLoading(false)
self.stopLoading.assertValueCount(1)

self.vm.inputs.isLoading(false)
self.stopLoading.assertValueCount(1)

self.vm.inputs.isLoading(true)
self.stopLoading.assertValueCount(1)

self.vm.inputs.isLoading(false)
self.stopLoading.assertValueCount(2)
}
}
17 changes: 17 additions & 0 deletions Library/ViewModels/PledgeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public protocol PledgeViewModelOutputs {
var showApplePayAlert: Signal<(String, String), Never> { get }
var submitButtonEnabled: Signal<Bool, Never> { get }
var submitButtonHidden: Signal<Bool, Never> { get }
var submitButtonIsLoading: Signal<Bool, Never> { get }
var submitButtonTitle: Signal<String, Never> { get }
var title: Signal<String, Never> { get }
var updatePledgeFailedWithError: Signal<String, Never> { get }
Expand Down Expand Up @@ -449,6 +450,21 @@ public class PledgeViewModel: PledgeViewModelType, PledgeViewModelInputs, Pledge
updateBackingEvent.filter { $0.isTerminating }.mapConst(true)
)

let createButtonIsLoading = Signal.merge(
createButtonTapped.mapConst(true),
createBackingEvent.filter { $0.isTerminating }.mapConst(false)
)

let updateButtonIsLoading = Signal.merge(
updateButtonTapped.mapConst(true),
updateBackingEvent.filter { $0.isTerminating }.mapConst(false)
)

self.submitButtonIsLoading = Signal.merge(
createButtonIsLoading,
updateButtonIsLoading
)

// MARK: - Success/Failure Create

let createPaymentAuthorizationDidFinishSignal = willCreateApplePayBacking
Expand Down Expand Up @@ -639,6 +655,7 @@ public class PledgeViewModel: PledgeViewModelType, PledgeViewModelInputs, Pledge
public let showApplePayAlert: Signal<(String, String), Never>
public let submitButtonEnabled: Signal<Bool, Never>
public let submitButtonHidden: Signal<Bool, Never>
public let submitButtonIsLoading: Signal<Bool, Never>
public let submitButtonTitle: Signal<String, Never>
public let title: Signal<String, Never>
public let updatePledgeFailedWithError: Signal<String, Never>
Expand Down
Loading