From 93feddc1da87d425ad190af4fddde9cbd79dd48e Mon Sep 17 00:00:00 2001 From: Bruno Muniz Azevedo Filho <bruno@elixxir.io> Date: Sat, 9 Jul 2022 18:58:29 -0300 Subject: [PATCH] Implement animated scroll tab alternating --- .../SearchContainerController.swift | 110 ++++ .../Controllers/SearchController.swift | 487 ------------------ .../Controllers/SearchEmailController.swift | 9 + .../Controllers/SearchPhoneController.swift | 9 + .../Controllers/SearchQRController.swift | 9 + .../Controllers/SearchTableController.swift | 43 +- .../SearchUsernameController.swift | 9 + .../SearchFeature/Views/FilterItemView.swift | 68 --- Sources/SearchFeature/Views/SearchCell.swift | 101 ++-- .../Views/SearchContainerView.swift | 17 +- .../SearchFeature/Views/SearchEmailView.swift | 28 + .../SearchFeature/Views/SearchPhoneView.swift | 28 + .../SearchFeature/Views/SearchQRView.swift | 28 + .../Views/SearchSegmentedButton.swift | 47 +- .../Views/SearchSegmentedControl.swift | 126 ++--- .../Views/SearchUsernamePlaceholderView.swift | 20 + .../Views/SearchUsernameView.swift | 36 ++ Sources/SearchFeature/Views/SearchView.swift | 129 ----- 18 files changed, 460 insertions(+), 844 deletions(-) delete mode 100644 Sources/SearchFeature/Controllers/SearchController.swift create mode 100644 Sources/SearchFeature/Controllers/SearchEmailController.swift create mode 100644 Sources/SearchFeature/Controllers/SearchPhoneController.swift create mode 100644 Sources/SearchFeature/Controllers/SearchQRController.swift create mode 100644 Sources/SearchFeature/Controllers/SearchUsernameController.swift delete mode 100644 Sources/SearchFeature/Views/FilterItemView.swift create mode 100644 Sources/SearchFeature/Views/SearchEmailView.swift create mode 100644 Sources/SearchFeature/Views/SearchPhoneView.swift create mode 100644 Sources/SearchFeature/Views/SearchQRView.swift create mode 100644 Sources/SearchFeature/Views/SearchUsernamePlaceholderView.swift create mode 100644 Sources/SearchFeature/Views/SearchUsernameView.swift delete mode 100644 Sources/SearchFeature/Views/SearchView.swift diff --git a/Sources/SearchFeature/Controllers/SearchContainerController.swift b/Sources/SearchFeature/Controllers/SearchContainerController.swift index f42bb1ad..1baedc82 100644 --- a/Sources/SearchFeature/Controllers/SearchContainerController.swift +++ b/Sources/SearchFeature/Controllers/SearchContainerController.swift @@ -1,6 +1,7 @@ import UIKit import Theme import Shared +import Combine import DependencyInjection public final class SearchContainerController: UIViewController { @@ -8,8 +9,16 @@ public final class SearchContainerController: UIViewController { lazy private var screenView = SearchContainerView() + private var cancellables = Set<AnyCancellable>() + private let qrController = SearchQRController() + private let emailController = SearchEmailController() + private let phoneController = SearchPhoneController() + private let usernameController = SearchUsernameController() + public override func loadView() { view = screenView + screenView.scrollView.delegate = self + embedControllers() } public override func viewWillAppear(_ animated: Bool) { @@ -24,6 +33,7 @@ public final class SearchContainerController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() setupNavigationBar() + setupBindings() } private func setupNavigationBar() { @@ -42,7 +52,107 @@ public final class SearchContainerController: UIViewController { ) } + private func setupBindings() { + screenView.segmentedControl + .actionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + let page = CGFloat($0.rawValue) + let point: CGPoint = CGPoint(x: screenView.frame.width * page, y: 0.0) + screenView.scrollView.setContentOffset(point, animated: true) + }.store(in: &cancellables) + } + @objc private func didTapBack() { navigationController?.popViewController(animated: true) } + + private func embedControllers() { + addChild(qrController) + addChild(emailController) + addChild(phoneController) + addChild(usernameController) + + screenView.scrollView.addSubview(qrController.view) + screenView.scrollView.addSubview(emailController.view) + screenView.scrollView.addSubview(phoneController.view) + screenView.scrollView.addSubview(usernameController.view) + + usernameController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) + $0.left.equalToSuperview() + $0.right.equalTo(emailController.view.snp.left) + } + + emailController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) + $0.right.equalTo(phoneController.view.snp.left) + } + + phoneController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) + $0.right.equalTo(qrController.view.snp.left) + } + + qrController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) + } + + qrController.didMove(toParent: self) + emailController.didMove(toParent: self) + phoneController.didMove(toParent: self) + usernameController.didMove(toParent: self) + } +} + +extension SearchContainerController: UIScrollViewDelegate { + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let pageOffset = scrollView.contentOffset.x / view.frame.width + scrollSegmentedControlTrack(using: pageOffset) + updateSegmentedControlButtonsColor(using: pageOffset) + } + + private func scrollSegmentedControlTrack(using pageOffset: CGFloat) { + let amountOfTabs = 4.0 + let tabWidth = screenView.bounds.width / amountOfTabs + + if let leftConstraint = screenView.segmentedControl.leftConstraint { + leftConstraint.update(offset: pageOffset * tabWidth) + } + } + + private func updateSegmentedControlButtonsColor(using pageOffset: CGFloat) { + let qrRate = highlightRateFor(page: 3, offset: pageOffset) + let emailRate = highlightRateFor(page: 1, offset: pageOffset) + let phoneRate = highlightRateFor(page: 2, offset: pageOffset) + let usernameRate = highlightRateFor(page: 0, offset: pageOffset) + + screenView.segmentedControl.qrCodeButton.updateHighlighting(rate: qrRate) + screenView.segmentedControl.emailButton.updateHighlighting(rate: emailRate) + screenView.segmentedControl.phoneButton.updateHighlighting(rate: phoneRate) + screenView.segmentedControl.usernameButton.updateHighlighting(rate: usernameRate) + } + + private func highlightRateFor(page: CGFloat, offset: CGFloat) -> CGFloat { + let lowerBound = page - 1 + let upperBound = page + 1 + + if offset > lowerBound && offset < upperBound { + if (offset - lowerBound) > 1 { + return 1 - (offset - page) + } else { + return offset - lowerBound + } + } else { + return 0 + } + } } diff --git a/Sources/SearchFeature/Controllers/SearchController.swift b/Sources/SearchFeature/Controllers/SearchController.swift deleted file mode 100644 index 809682d6..00000000 --- a/Sources/SearchFeature/Controllers/SearchController.swift +++ /dev/null @@ -1,487 +0,0 @@ -import HUD -import Theme -import UIKit -import Shared -import Models -import Combine -import Defaults -import XXModels -import Countries -import DrawerFeature -import DependencyInjection -import ScrollViewController - -final class SearchController: UIViewController { - @KeyObject(.email, defaultValue: nil) var email: String? - @KeyObject(.phone, defaultValue: nil) var phone: String? - @KeyObject(.sharingEmail, defaultValue: false) var isSharingEmail: Bool - @KeyObject(.sharingPhone, defaultValue: false) var isSharingPhone: Bool - - @Dependency private var hud: HUDType - @Dependency private var coordinator: SearchCoordinating - - lazy private var tableController = SearchTableController(viewModel) - lazy private var screenView = SearchView { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.Ud.Placeholder.Drawer.action - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.Ud.Placeholder.Drawer.title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerLinkText( - text: Localized.Ud.Placeholder.Drawer.subtitle, - urlString: "https://links.xx.network/adrp", - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &self.drawerCancellables) - - self.coordinator.toDrawer(drawer, from: self) - } - - private let viewModel = SearchViewModel() - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() - - override func loadView() { - view = screenView - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - viewModel.didAppear() - } - - override func viewDidLoad() { - super.viewDidLoad() - setupNavigationBar() - setupTableView() - setupBindings() - setupFilterBindings() - } - - private func setupTableView() { - addChild(tableController) - screenView.addSubview(tableController.view) - - tableController.view.snp.makeConstraints { - $0.top.equalTo(screenView.stack.snp.bottom).offset(20) - $0.left.bottom.right.equalToSuperview() - } - - tableController.didMove(toParent: self) - tableController.tableView.delegate = self - screenView.bringSubviewToFront(screenView.empty) - screenView.bringSubviewToFront(screenView.placeholder) - } - - private func setupNavigationBar() { - navigationItem.backButtonTitle = " " - - let titleLabel = UILabel() - titleLabel.text = Localized.Ud.title - titleLabel.textColor = Asset.neutralActive.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - - let backButton = UIButton.back() - backButton.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [backButton, titleLabel]) - ) - } - - private func setupBindings() { - viewModel.successPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in presentSucessDrawerFor(contact: $0) } - .store(in: &cancellables) - - viewModel.hudPublisher - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - viewModel.coverTrafficPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in presentCoverTrafficDrawer() } - .store(in: &cancellables) - - viewModel - .itemsRelay - .removeDuplicates() - .map(\.count) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.empty.isHidden = $0 > 0 } - .store(in: &cancellables) - - viewModel.placeholderPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.placeholder.isHidden = !$0 } - .store(in: &cancellables) - - viewModel.statePublisher - .map(\.country) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - screenView.phoneInput.set(prefix: $0.prefixWithFlag) - screenView.phoneInput.update(placeholder: $0.example) - } - .store(in: &cancellables) - - screenView.input - .textPublisher - .removeDuplicates() - .compactMap { $0 } - .sink { [unowned self] in viewModel.didInput($0) } - .store(in: &cancellables) - - screenView.input - .returnPublisher - .sink { [unowned self] in viewModel.didTapSearch() } - .store(in: &cancellables) - - screenView.phoneInput - .returnPublisher - .sink { [unowned self] in viewModel.didTapSearch() } - .store(in: &cancellables) - - screenView - .phoneInput - .textPublisher - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in viewModel.didInputPhone($0) } - .store(in: &cancellables) - - screenView - .phoneInput - .codePublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - coordinator.toCountries(from: self) { - self.viewModel.didChooseCountry($0) - } - }.store(in: &cancellables) - } - - private func setupFilterBindings() { - screenView.username - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in viewModel.didSelect(filter: .username) } - .store(in: &cancellables) - - screenView.phone - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in viewModel.didSelect(filter: .phone) } - .store(in: &cancellables) - - screenView.email - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in viewModel.didSelect(filter: .email) } - .store(in: &cancellables) - - viewModel.statePublisher - .map(\.selectedFilter) - .removeDuplicates() - .sink { [unowned self] in screenView.alternateFieldsOver(filter: $0) } - .store(in: &cancellables) - - viewModel.statePublisher - .map(\.selectedFilter) - .removeDuplicates() - .dropFirst() - .sink { [unowned self] in screenView.select(filter: $0) } - .store(in: &cancellables) - } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } - - func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { - let contact = viewModel.itemsRelay.value[indexPath.row] - - guard contact.authStatus == .stranger else { - coordinator.toContact(contact, from: self) - return - } - - presentRequestDrawer(forContact: contact) - } -} - -extension SearchController: UITableViewDelegate {} - -// MARK: - Contact Request Drawer - -extension SearchController { - private func presentRequestDrawer(forContact contact: Contact) { - var items: [DrawerItem] = [] - - let drawerTitle = DrawerText( - font: Fonts.Mulish.extraBold.font(size: 26.0), - text: Localized.Ud.RequestDrawer.title, - color: Asset.neutralDark.color, - spacingAfter: 20 - ) - - var subtitleFragment = "Share your information with #\(contact.username ?? "")" - - if let email = contact.email { - subtitleFragment.append(contentsOf: " (\(email))#") - } else if let phone = contact.phone { - subtitleFragment.append(contentsOf: " (\(Country.findFrom(phone).prefix) \(phone.dropLast(2)))#") - } else { - subtitleFragment.append(contentsOf: "#") - } - - subtitleFragment.append(contentsOf: " so they know its you.") - - let drawerSubtitle = DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: subtitleFragment, - color: Asset.neutralDark.color, - spacingAfter: 31.5, - customAttributes: [ - .font: Fonts.Mulish.regular.font(size: 16.0) as Any, - .foregroundColor: Asset.brandPrimary.color - ] - ) - - items.append(contentsOf: [ - drawerTitle, - drawerSubtitle - ]) - - if let email = email { - let drawerEmail = DrawerSwitch( - title: Localized.Ud.RequestDrawer.email, - content: email, - spacingAfter: phone != nil ? 23 : 31, - isInitiallyOn: isSharingEmail - ) - - items.append(drawerEmail) - - drawerEmail.isOnPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] in self?.isSharingEmail = $0 } - .store(in: &drawerCancellables) - } - - if let phone = phone { - let drawerPhone = DrawerSwitch( - title: Localized.Ud.RequestDrawer.phone, - content: "\(Country.findFrom(phone).prefix) \(phone.dropLast(2))", - spacingAfter: 31, - isInitiallyOn: isSharingPhone - ) - - items.append(drawerPhone) - - drawerPhone.isOnPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] in self?.isSharingPhone = $0 } - .store(in: &drawerCancellables) - } - - let drawerSendButton = DrawerCapsuleButton( - model: .init( - title: Localized.Ud.RequestDrawer.send, - style: .brandColored - ), spacingAfter: 5 - ) - - let drawerCancelButton = DrawerCapsuleButton( - model: .init( - title: Localized.Ud.RequestDrawer.cancel, - style: .simplestColoredBrand - ), spacingAfter: 5 - ) - - items.append(contentsOf: [drawerSendButton, drawerCancelButton]) - let drawer = DrawerController(with: items) - - drawerSendButton.action - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - drawer.dismiss(animated: true) { - self.viewModel.didTapRequest(contact: contact) - } - }.store(in: &drawerCancellables) - - drawerCancelButton.action - .receive(on: DispatchQueue.main) - .sink { drawer.dismiss(animated: true) } - .store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } -} - -// MARK: - Cover Traffic Drawer - -extension SearchController { - private func presentCoverTrafficDrawer() { - let enableButton = CapsuleButton() - enableButton.set( - style: .brandColored, - title: Localized.ChatList.Traffic.positive - ) - - let dismissButton = CapsuleButton() - dismissButton.set( - style: .seeThrough, - title: Localized.ChatList.Traffic.negative - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.ChatList.Traffic.title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: Localized.ChatList.Traffic.subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 39 - ), - DrawerStack( - axis: .horizontal, - spacing: 20, - distribution: .fillEqually, - views: [enableButton, dismissButton] - ) - ]) - - enableButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - self.viewModel.didEnableCoverTraffic() - } - }.store(in: &drawerCancellables) - - dismissButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } -} - -extension SearchController { - private func presentSucessDrawerFor(contact: Contact) { - var items: [DrawerItem] = [] - - let drawerTitle = DrawerText( - font: Fonts.Mulish.extraBold.font(size: 26.0), - text: Localized.Ud.NicknameDrawer.title, - color: Asset.neutralDark.color, - spacingAfter: 20 - ) - - let drawerSubtitle = DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: Localized.Ud.NicknameDrawer.subtitle, - color: Asset.neutralDark.color, - spacingAfter: 20 - ) - - items.append(contentsOf: [ - drawerTitle, - drawerSubtitle - ]) - - let drawerNicknameInput = DrawerInput( - placeholder: contact.username!, - validator: .init( - wrongIcon: .image(Asset.sharedError.image), - correctIcon: .image(Asset.sharedSuccess.image), - shouldAcceptPlaceholder: true - ), - spacingAfter: 29 - ) - - items.append(drawerNicknameInput) - - let drawerSaveButton = DrawerCapsuleButton( - model: .init( - title: Localized.Ud.NicknameDrawer.save, - style: .brandColored - ), spacingAfter: 5 - ) - - items.append(drawerSaveButton) - - let drawer = DrawerController(with: items) - var nickname: String? - var allowsSave = true - - drawerNicknameInput.validationPublisher - .receive(on: DispatchQueue.main) - .sink { allowsSave = $0 } - .store(in: &drawerCancellables) - - drawerNicknameInput.inputPublisher - .receive(on: DispatchQueue.main) - .sink { - guard !$0.isEmpty else { - nickname = contact.username - return - } - - nickname = $0 - } - .store(in: &drawerCancellables) - - drawerSaveButton.action - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - guard allowsSave else { return } - - drawer.dismiss(animated: true) { - self.viewModel.didSet(nickname: nickname ?? contact.username!, for: contact) - } - } - .store(in: &drawerCancellables) - - coordinator.toNicknameDrawer(drawer, from: self) - } -} diff --git a/Sources/SearchFeature/Controllers/SearchEmailController.swift b/Sources/SearchFeature/Controllers/SearchEmailController.swift new file mode 100644 index 00000000..1380e7f1 --- /dev/null +++ b/Sources/SearchFeature/Controllers/SearchEmailController.swift @@ -0,0 +1,9 @@ +import UIKit + +final class SearchEmailController: UIViewController { + lazy private var screenView = SearchEmailView() + + override func loadView() { + view = screenView + } +} diff --git a/Sources/SearchFeature/Controllers/SearchPhoneController.swift b/Sources/SearchFeature/Controllers/SearchPhoneController.swift new file mode 100644 index 00000000..63d6f568 --- /dev/null +++ b/Sources/SearchFeature/Controllers/SearchPhoneController.swift @@ -0,0 +1,9 @@ +import UIKit + +final class SearchPhoneController: UIViewController { + lazy private var screenView = SearchPhoneView() + + override func loadView() { + view = screenView + } +} diff --git a/Sources/SearchFeature/Controllers/SearchQRController.swift b/Sources/SearchFeature/Controllers/SearchQRController.swift new file mode 100644 index 00000000..04fe440f --- /dev/null +++ b/Sources/SearchFeature/Controllers/SearchQRController.swift @@ -0,0 +1,9 @@ +import UIKit + +final class SearchQRController: UIViewController { + lazy private var screenView = SearchQRView() + + override func loadView() { + view = screenView + } +} diff --git a/Sources/SearchFeature/Controllers/SearchTableController.swift b/Sources/SearchFeature/Controllers/SearchTableController.swift index a18b5856..67459cff 100644 --- a/Sources/SearchFeature/Controllers/SearchTableController.swift +++ b/Sources/SearchFeature/Controllers/SearchTableController.swift @@ -4,14 +4,10 @@ import Combine import XXModels final class SearchTableController: UITableViewController { - // MARK: Properties - private let viewModel: SearchViewModel private var cancellables = Set<AnyCancellable>() private(set) var dataSource = [Contact]() - // MARK: Lifecycle - init(_ viewModel: SearchViewModel) { self.viewModel = viewModel super.init(style: .grouped) @@ -21,21 +17,11 @@ final class SearchTableController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - setupTableView() - setupBindings() - } - - // MARK: Private - - private func setupTableView() { tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.register(SearchCell.self) - } - private func setupBindings() { - viewModel - .itemsRelay + viewModel.itemsRelay .receive(on: DispatchQueue.main) .sink { [unowned self] in dataSource = $0 @@ -43,18 +29,27 @@ final class SearchTableController: UITableViewController { }.store(in: &cancellables) } - // MARK: UITableViewDataSource - - override func tableView(_ tableView: UITableView, - cellForRowAt indexPath: IndexPath) -> UITableViewCell { + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: SearchCell.self) - cell.title.text = dataSource[indexPath.row].username - cell.subtitle.text = dataSource[indexPath.row].username - cell.avatar.setupProfile(title: dataSource[indexPath.row].username!, image: nil, size: .large) + let username = dataSource[indexPath.row].username! + + cell.setup( + title: username, + subtitle: username, + avatarTitle: username, + avatarImage: nil, + avatarSize: .large + ) + return cell } - override func tableView(_ tableView: UITableView, - numberOfRowsInSection section: Int) -> Int { dataSource.count } + override func tableView( + _: UITableView, + numberOfRowsInSection: Int + ) -> Int { dataSource.count } } diff --git a/Sources/SearchFeature/Controllers/SearchUsernameController.swift b/Sources/SearchFeature/Controllers/SearchUsernameController.swift new file mode 100644 index 00000000..136951c8 --- /dev/null +++ b/Sources/SearchFeature/Controllers/SearchUsernameController.swift @@ -0,0 +1,9 @@ +import UIKit + +final class SearchUsernameController: UIViewController { + lazy private var screenView = SearchUsernameView() + + override func loadView() { + view = screenView + } +} diff --git a/Sources/SearchFeature/Views/FilterItemView.swift b/Sources/SearchFeature/Views/FilterItemView.swift deleted file mode 100644 index 2bf4d2a8..00000000 --- a/Sources/SearchFeature/Views/FilterItemView.swift +++ /dev/null @@ -1,68 +0,0 @@ -import UIKit -import Shared - -final class FilterItemView: UIControl { - enum Style { - case selected - case unselected - } - - private let title = UILabel() - private let image = UIImageView() - - private var icon: UIImage? - - var style: Style = .unselected { - didSet { - image.image = icon?.withRenderingMode(.alwaysTemplate) - - switch style { - case .selected: - backgroundColor = Asset.brandDefault.color - image.tintColor = Asset.neutralWhite.color - title.textColor = Asset.neutralWhite.color - title.font = Fonts.Mulish.bold.font(size: 14.0) - layer.borderColor = Asset.brandDefault.color.cgColor - - case .unselected: - image.tintColor = Asset.neutralActive.color - title.textColor = Asset.neutralActive.color - backgroundColor = Asset.neutralSecondary.color - title.font = Fonts.Mulish.regular.font(size: 14.0) - layer.borderColor = Asset.neutralLine.color.cgColor - } - } - } - - init() { - super.init(frame: .zero) - - layer.borderWidth = 1 - layer.cornerRadius = 4 - image.contentMode = .center - - let stack = UIStackView() - stack.isUserInteractionEnabled = false - - stack.spacing = 8 - stack.addArrangedSubview(image) - stack.addArrangedSubview(title) - - addSubview(stack) - - stack.snp.makeConstraints { $0.center.equalToSuperview() } - snp.makeConstraints { $0.height.equalTo(40) } - } - - required init?(coder: NSCoder) { nil } - - func set( - title: String, - icon: UIImage?, - style: Style = .unselected - ) { - self.icon = icon - self.style = style - self.title.text = title - } -} diff --git a/Sources/SearchFeature/Views/SearchCell.swift b/Sources/SearchFeature/Views/SearchCell.swift index 88194257..08efd502 100644 --- a/Sources/SearchFeature/Views/SearchCell.swift +++ b/Sources/SearchFeature/Views/SearchCell.swift @@ -2,72 +2,81 @@ import UIKit import Shared final class SearchCell: UITableViewCell { - // MARK: UI - - let title = UILabel() - let subtitle = UILabel() - let separator = UIView() - let avatar = AvatarView() - - // MARK: Lifecycle + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let separatorView = UIView() + private let avatarView = AvatarView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - setup() - } - - required init?(coder: NSCoder) { nil } - - override func prepareForReuse() { - super.prepareForReuse() - title.text = nil - } - // MARK: Private - - private func setup() { selectionStyle = .none backgroundColor = Asset.neutralWhite.color - title.textColor = Asset.neutralActive.color - subtitle.textColor = Asset.neutralDisabled.color - separator.backgroundColor = Asset.neutralLine.color + titleLabel.textColor = Asset.neutralActive.color + subtitleLabel.textColor = Asset.neutralDisabled.color + separatorView.backgroundColor = Asset.neutralLine.color - title.font = Fonts.Mulish.semiBold.font(size: 14.0) - subtitle.font = Fonts.Mulish.regular.font(size: 12.0) + titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + subtitleLabel.font = Fonts.Mulish.regular.font(size: 12.0) - contentView.addSubview(title) - contentView.addSubview(avatar) - contentView.addSubview(subtitle) - contentView.addSubview(separator) + contentView.addSubview(titleLabel) + contentView.addSubview(avatarView) + contentView.addSubview(subtitleLabel) + contentView.addSubview(separatorView) setupConstraints() } + required init?(coder: NSCoder) { nil } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + subtitleLabel.text = nil + avatarView.prepareForReuse() + } + + func setup( + title: String, + subtitle: String, + avatarTitle: String, + avatarImage: Data?, + avatarSize: AvatarView.Size + ) { + titleLabel.text = title + subtitleLabel.text = subtitle + avatarView.setupProfile( + title: avatarTitle, + image: avatarImage, + size: avatarSize + ) + } + private func setupConstraints() { - title.snp.makeConstraints { make in - make.top.equalToSuperview().offset(10) - make.left.equalTo(avatar.snp.right).offset(16) - make.right.lessThanOrEqualToSuperview().offset(-20) + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(10) + $0.left.equalTo(avatarView.snp.right).offset(16) + $0.right.lessThanOrEqualToSuperview().offset(-20) } - subtitle.snp.makeConstraints { make in - make.top.equalTo(title.snp.bottom).offset(3) - make.left.equalTo(title) - make.bottom.equalToSuperview().offset(-22) + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(3) + $0.left.equalTo(titleLabel) + $0.bottom.equalToSuperview().offset(-22) } - avatar.snp.makeConstraints { make in - make.left.equalToSuperview().offset(28) - make.width.height.equalTo(48) - make.bottom.equalToSuperview().offset(-16) + avatarView.snp.makeConstraints { + $0.left.equalToSuperview().offset(28) + $0.width.height.equalTo(48) + $0.bottom.equalToSuperview().offset(-16) } - separator.snp.makeConstraints { make in - make.height.equalTo(1) - make.left.equalToSuperview().offset(24) - make.right.equalToSuperview().offset(-24) - make.bottom.equalToSuperview() + separatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) + $0.bottom.equalToSuperview() } } } diff --git a/Sources/SearchFeature/Views/SearchContainerView.swift b/Sources/SearchFeature/Views/SearchContainerView.swift index 5b350905..3a270bc5 100644 --- a/Sources/SearchFeature/Views/SearchContainerView.swift +++ b/Sources/SearchFeature/Views/SearchContainerView.swift @@ -12,17 +12,24 @@ final class SearchContainerView: UIView { addSubview(segmentedControl) addSubview(scrollView) - scrollView.snp.makeConstraints { - $0.edges.equalToSuperview() - } + setupConstraints() + } + required init?(coder: NSCoder) { nil } + + private func setupConstraints() { segmentedControl.snp.makeConstraints { $0.top.equalTo(safeAreaLayoutGuide).offset(10) $0.left.equalToSuperview() $0.right.equalToSuperview() $0.height.equalTo(60) } - } - required init?(coder: NSCoder) { nil } + scrollView.snp.makeConstraints { + $0.top.equalTo(segmentedControl.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + } } diff --git a/Sources/SearchFeature/Views/SearchEmailView.swift b/Sources/SearchFeature/Views/SearchEmailView.swift new file mode 100644 index 00000000..053c6259 --- /dev/null +++ b/Sources/SearchFeature/Views/SearchEmailView.swift @@ -0,0 +1,28 @@ +import UIKit +import Shared +import InputField + +final class SearchEmailView: UIView { + let inputField = InputField() + + init() { + super.init(frame: .zero) + + inputField.setup( + style: .regular, + title: "Email", + placeholder: "Email" + ) + + addSubview(inputField) + + inputField.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.left.equalToSuperview().offset(15) + $0.right.equalToSuperview().offset(-15) + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/SearchFeature/Views/SearchPhoneView.swift b/Sources/SearchFeature/Views/SearchPhoneView.swift new file mode 100644 index 00000000..1868cec6 --- /dev/null +++ b/Sources/SearchFeature/Views/SearchPhoneView.swift @@ -0,0 +1,28 @@ +import UIKit +import Shared +import InputField + +final class SearchPhoneView: UIView { + let inputField = InputField() + + init() { + super.init(frame: .zero) + + inputField.setup( + style: .regular, + title: "Phone", + placeholder: "Phone" + ) + + addSubview(inputField) + + inputField.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.left.equalToSuperview().offset(15) + $0.right.equalToSuperview().offset(-15) + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/SearchFeature/Views/SearchQRView.swift b/Sources/SearchFeature/Views/SearchQRView.swift new file mode 100644 index 00000000..4adae045 --- /dev/null +++ b/Sources/SearchFeature/Views/SearchQRView.swift @@ -0,0 +1,28 @@ +import UIKit +import Shared +import InputField + +final class SearchQRView: UIView { + let inputField = InputField() + + init() { + super.init(frame: .zero) + + inputField.setup( + style: .regular, + title: "QR", + placeholder: "QR" + ) + + addSubview(inputField) + + inputField.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.left.equalToSuperview().offset(15) + $0.right.equalToSuperview().offset(-15) + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/SearchFeature/Views/SearchSegmentedButton.swift b/Sources/SearchFeature/Views/SearchSegmentedButton.swift index 1142ead7..bc4c0f28 100644 --- a/Sources/SearchFeature/Views/SearchSegmentedButton.swift +++ b/Sources/SearchFeature/Views/SearchSegmentedButton.swift @@ -2,8 +2,10 @@ import UIKit import Shared final class SearchSegmentedButton: UIControl { - let titleLabel = UILabel() - let imageView = UIImageView() + private let titleLabel = UILabel() + private let imageView = UIImageView() + private let highlightColor = Asset.brandPrimary.color + private let discreteColor = Asset.neutralDisabled.color init() { super.init(frame: .zero) @@ -16,6 +18,35 @@ final class SearchSegmentedButton: UIControl { addSubview(titleLabel) addSubview(imageView) + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + func setup( + title: String, + icon: UIImage, + iconColor: UIColor = Asset.neutralDisabled.color, + titleColor: UIColor = Asset.neutralDisabled.color + ) { + self.imageView.image = icon + self.titleLabel.text = title + self.imageView.tintColor = iconColor + self.titleLabel.textColor = titleColor + } + + func updateHighlighting(rate: CGFloat) { + let color = UIColor.fade( + from: discreteColor, + to: highlightColor, + pcent: rate + ) + + imageView.tintColor = color + titleLabel.textColor = color + } + + private func setupConstraints() { imageView.snp.makeConstraints { $0.top.equalToSuperview().offset(7.5) $0.centerX.equalToSuperview() @@ -27,16 +58,4 @@ final class SearchSegmentedButton: UIControl { $0.bottom.equalToSuperview().offset(-7.5) } } - - required init?(coder: NSCoder) { nil } - - func setup(title: String, icon: UIImage) { - titleLabel.text = title - imageView.image = icon - } - - func update(color: UIColor) { - imageView.tintColor = color - titleLabel.textColor = color - } } diff --git a/Sources/SearchFeature/Views/SearchSegmentedControl.swift b/Sources/SearchFeature/Views/SearchSegmentedControl.swift index af0e4271..212c78e6 100644 --- a/Sources/SearchFeature/Views/SearchSegmentedControl.swift +++ b/Sources/SearchFeature/Views/SearchSegmentedControl.swift @@ -1,53 +1,88 @@ import UIKit import Shared import SnapKit +import Combine final class SearchSegmentedControl: UIView { + enum Item: Int { + case username = 0 + case email + case phone + case qr + } + private let trackView = UIView() private let stackView = UIStackView() - private var leftConstraint: Constraint? private let trackIndicatorView = UIView() + private(set) var leftConstraint: Constraint? private(set) var usernameButton = SearchSegmentedButton() private(set) var emailButton = SearchSegmentedButton() private(set) var phoneButton = SearchSegmentedButton() private(set) var qrCodeButton = SearchSegmentedButton() + var actionPublisher: AnyPublisher<Item, Never> { + actionSubject.eraseToAnyPublisher() + } + + private var cancellables = Set<AnyCancellable>() + private let actionSubject = PassthroughSubject<Item, Never>() + init() { super.init(frame: .zero) trackView.backgroundColor = Asset.neutralLine.color trackIndicatorView.backgroundColor = Asset.brandPrimary.color - qrCodeButton.titleLabel.text = Localized.Ud.Tab.qr - emailButton.titleLabel.text = Localized.Ud.Tab.email - phoneButton.titleLabel.text = Localized.Ud.Tab.phone - usernameButton.titleLabel.text = Localized.Ud.Tab.username - - usernameButton.titleLabel.textColor = Asset.brandPrimary.color - emailButton.titleLabel.textColor = Asset.neutralDisabled.color - phoneButton.titleLabel.textColor = Asset.neutralDisabled.color - qrCodeButton.titleLabel.textColor = Asset.neutralDisabled.color - - usernameButton.imageView.tintColor = Asset.brandPrimary.color - emailButton.imageView.tintColor = Asset.neutralDisabled.color - phoneButton.imageView.tintColor = Asset.neutralDisabled.color - qrCodeButton.imageView.tintColor = Asset.neutralDisabled.color + usernameButton.setup( + title: Localized.Ud.Tab.username, + icon: Asset.searchTabUsername.image, + iconColor: Asset.brandPrimary.color, + titleColor: Asset.brandPrimary.color + ) - qrCodeButton.imageView.image = Asset.searchTabQr.image - emailButton.imageView.image = Asset.searchTabEmail.image - phoneButton.imageView.image = Asset.searchTabPhone.image - usernameButton.imageView.image = Asset.searchTabUsername.image + qrCodeButton.setup(title: Localized.Ud.Tab.qr, icon: Asset.searchTabQr.image) + emailButton.setup(title: Localized.Ud.Tab.email, icon: Asset.searchTabEmail.image) + phoneButton.setup(title: Localized.Ud.Tab.phone, icon: Asset.searchTabPhone.image) + stackView.distribution = .fillEqually stackView.addArrangedSubview(usernameButton) stackView.addArrangedSubview(emailButton) stackView.addArrangedSubview(phoneButton) stackView.addArrangedSubview(qrCodeButton) - stackView.distribution = .fillEqually stackView.backgroundColor = Asset.neutralWhite.color addSubview(stackView) addSubview(trackView) trackView.addSubview(trackIndicatorView) + setupBindings() + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + private func setupBindings() { + usernameButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.username) } + .store(in: &cancellables) + + emailButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.email) } + .store(in: &cancellables) + + phoneButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.phone) } + .store(in: &cancellables) + + qrCodeButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.qr) } + .store(in: &cancellables) + } + + private func setupConstraints() { stackView.snp.makeConstraints { $0.edges.equalToSuperview() } @@ -66,55 +101,4 @@ final class SearchSegmentedControl: UIView { $0.bottom.equalToSuperview() } } - - required init?(coder: NSCoder) { nil } - - func updateSwipePercentage(_ percentageScrolled: CGFloat) { - let amountOfTabs = 4.0 - let tabWidth = bounds.width / amountOfTabs - let leftOffset = percentageScrolled * tabWidth - - leftConstraint?.update(offset: leftOffset) - - let usernamePercentage = percentageScrolled > 1 ? 1 : percentageScrolled - let phonePercentage = percentageScrolled <= 1 ? 0 : percentageScrolled - 1 - let emailPercentage = percentageScrolled > 1 ? 1 - (percentageScrolled-1) : percentageScrolled - let qrPercentage = percentageScrolled > 1 ? 1 - (percentageScrolled-1) : percentageScrolled - - let usernameColor = UIColor.fade( - from: Asset.brandPrimary.color, - to: Asset.neutralDisabled.color, - pcent: usernamePercentage - ) - - let emailColor = UIColor.fade( - from: Asset.neutralDisabled.color, - to: Asset.brandPrimary.color, - pcent: emailPercentage - ) - - let phoneColor = UIColor.fade( - from: Asset.neutralDisabled.color, - to: Asset.brandPrimary.color, - pcent: phonePercentage - ) - - let qrColor = UIColor.fade( - from: Asset.brandPrimary.color, - to: Asset.neutralDisabled.color, - pcent: qrPercentage - ) - - usernameButton.imageView.tintColor = usernameColor - usernameButton.titleLabel.textColor = usernameColor - - emailButton.imageView.tintColor = emailColor - emailButton.titleLabel.textColor = emailColor - - phoneButton.imageView.tintColor = phoneColor - phoneButton.titleLabel.textColor = phoneColor - - qrCodeButton.imageView.tintColor = qrColor - qrCodeButton.titleLabel.textColor = qrColor - } } diff --git a/Sources/SearchFeature/Views/SearchUsernamePlaceholderView.swift b/Sources/SearchFeature/Views/SearchUsernamePlaceholderView.swift new file mode 100644 index 00000000..effcf607 --- /dev/null +++ b/Sources/SearchFeature/Views/SearchUsernamePlaceholderView.swift @@ -0,0 +1,20 @@ +import UIKit +import Shared + +final class SearchUsernamePlaceholderView: UIView { + let titleLabel = UILabel() + + init() { + super.init(frame: .zero) + + titleLabel.text = "[SearchUsernamePlaceholderView]" + + addSubview(titleLabel) + + titleLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/SearchFeature/Views/SearchUsernameView.swift b/Sources/SearchFeature/Views/SearchUsernameView.swift new file mode 100644 index 00000000..2dd66fcd --- /dev/null +++ b/Sources/SearchFeature/Views/SearchUsernameView.swift @@ -0,0 +1,36 @@ +import UIKit +import Shared +import InputField + +final class SearchUsernameView: UIView { + let inputField = InputField() + let placeholderView = SearchUsernamePlaceholderView() + + init() { + super.init(frame: .zero) + + inputField.setup( + style: .regular, + title: "Username", + placeholder: "Username" + ) + + addSubview(inputField) + addSubview(placeholderView) + + inputField.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.left.equalToSuperview().offset(15) + $0.right.equalToSuperview().offset(-15) + } + + placeholderView.snp.makeConstraints { + $0.top.equalTo(inputField.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/SearchFeature/Views/SearchView.swift b/Sources/SearchFeature/Views/SearchView.swift deleted file mode 100644 index e4b6b638..00000000 --- a/Sources/SearchFeature/Views/SearchView.swift +++ /dev/null @@ -1,129 +0,0 @@ -import UIKit -import Shared -import InputField - -final class SearchView: UIView { - private enum Constants { - static let phone = Localized.Ud.Tab.phone - static let email = Localized.Ud.Tab.email - static let username = Localized.Ud.Tab.username - } - - let input = InputField() - let stack = UIStackView() - let filters = UIStackView() - let email = FilterItemView() - let phone = FilterItemView() - let empty = SearchEmptyView() - let phoneInput = InputField() - let username = FilterItemView() - lazy var placeholder = SearchPlaceholderView { self.didTapInfo() } - - let didTapInfo: () -> Void - - init(didTapInfo: @escaping () -> Void) { - self.didTapInfo = didTapInfo - - super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - func alternateFieldsOver(filter: SelectedFilter) { - switch filter { - case .username, .email: - input.isHidden = false - phoneInput.isHidden = true - case .phone: - input.isHidden = true - phoneInput.isHidden = false - } - } - - func select(filter: SelectedFilter) { - [username, email, phone].forEach { $0.style = .unselected } - - switch filter { - case .username: - username.style = .selected - empty.set(filter: Constants.username.lowercased()) - input.makeFirstResponder() - case .email: - email.style = .selected - empty.set(filter: Constants.email.lowercased()) - input.makeFirstResponder() - case .phone: - phone.style = .selected - empty.set(filter: Constants.phone.lowercased()) - phoneInput.makeFirstResponder() - } - } - - // MARK: Private - - private func setup() { - backgroundColor = Asset.neutralWhite.color - - input.setup( - placeholder: Localized.Ud.title, - leftView: .image(Asset.lens.image.withTintColor(Asset.neutralDisabled.color)), - accessibility: Localized.Accessibility.Search.input, - allowsEmptySpace: false, - autocapitalization: .none, - returnKeyType: .search, - clearable: true - ) - - phoneInput.setup( - style: .phone, - placeholder: "1509192596", - rightView: .image(Asset.searchLens.image), - accessibility: Localized.Accessibility.Search.phoneInput, - keyboardType: .numberPad, - contentType: .telephoneNumber, - returnKeyType: .search, - toolbarButtonTitle: Localized.Shared.Search.placeholder, - codeAccessibility: Localized.Accessibility.Search.countryCode - ) - - email.set(title: Constants.email, icon: Asset.searchEmail.image) - phone.set(title: Constants.phone, icon: Asset.searchPhone.image) - username.set(title: Constants.username, icon: Asset.searchUsername.image, style: .selected) - - email.accessibilityIdentifier = Localized.Accessibility.Search.email - phone.accessibilityIdentifier = Localized.Accessibility.Search.phone - username.accessibilityIdentifier = Localized.Accessibility.Search.username - - filters.addArrangedSubview(username) - filters.addArrangedSubview(email) - filters.addArrangedSubview(phone) - filters.distribution = .fillEqually - filters.spacing = 20 - - stack.axis = .vertical - stack.addArrangedSubview(filters) - stack.addArrangedSubview(input) - stack.addArrangedSubview(phoneInput) - - addSubview(stack) - addSubview(empty) - addSubview(placeholder) - - stack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(14) - make.left.equalToSuperview().offset(17) - make.right.equalToSuperview().offset(-17) - } - - placeholder.snp.makeConstraints { make in - make.top.equalTo(stack.snp.bottom) - make.left.bottom.right.equalToSuperview() - } - - empty.snp.makeConstraints { make in - make.top.equalTo(stack.snp.bottom) - make.left.bottom.right.equalToSuperview() - } - } -} -- GitLab