From 6285cb95faff300d20cbbbbcf06a54c54f31eee6 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 20 Feb 2021 19:54:08 +0800 Subject: [PATCH] fix: AutoLayout issue. Update keyboard listener. Expose server error message --- Mastodon.xcodeproj/project.pbxproj | 4 + .../AuthenticationViewController.swift | 13 ++- .../MastodonRegisterViewController.swift | 99 ++++++++++++------- .../Register/MastodonRegisterViewModel.swift | 9 +- .../Service/KeyboardResponderService.swift | 87 ++++++++++++++++ .../API/Error/Mastodon+API+Error.swift | 24 +++++ 6 files changed, 194 insertions(+), 42 deletions(-) create mode 100644 Mastodon/Service/KeyboardResponderService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 889bd4651..3eac67d43 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; }; DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; + DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */; }; DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */; }; @@ -266,6 +267,7 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastodonUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardResponderService.swift; sourceTree = ""; }; DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonUser.swift"; sourceTree = ""; }; @@ -444,6 +446,7 @@ children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, + DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, ); path = Service; sourceTree = ""; @@ -1247,6 +1250,7 @@ 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, + DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, diff --git a/Mastodon/Scene/Authentication/AuthenticationViewController.swift b/Mastodon/Scene/Authentication/AuthenticationViewController.swift index e0a561910..b02cf3c4c 100644 --- a/Mastodon/Scene/Authentication/AuthenticationViewController.swift +++ b/Mastodon/Scene/Authentication/AuthenticationViewController.swift @@ -273,6 +273,7 @@ extension AuthenticationViewController { } guard viewModel.isIdle.value else { return } viewModel.isRegistering.value = true + context.apiService.instance(domain: domain) .compactMap { [weak self] response -> AnyPublisher, Error>? in guard let self = self else { return nil } @@ -289,9 +290,11 @@ extension AuthenticationViewController { } return authenticateInfo } - .compactMap { [weak self] authenticateInfo -> AnyPublisher, Error>? in + .compactMap { [weak self] authenticateInfo -> AnyPublisher<(Mastodon.Response.Content, AuthenticationViewModel.AuthenticateInfo), Error>? in guard let self = self else { return nil } return self.context.apiService.applicationAccessToken(domain: domain, clientID: authenticateInfo.clientID, clientSecret: authenticateInfo.clientSecret) + .map { ($0, authenticateInfo) } + .eraseToAnyPublisher() } .switchToLatest() .receive(on: DispatchQueue.main) @@ -305,9 +308,13 @@ extension AuthenticationViewController { case .finished: break } - } receiveValue: { [weak self] response in + } receiveValue: { [weak self] response, authenticateInfo in guard let self = self else { return } - let mastodonRegisterViewModel = MastodonRegisterViewModel(domain: domain, applicationToken: response.value) + let mastodonRegisterViewModel = MastodonRegisterViewModel( + domain: domain, + authenticateInfo: authenticateInfo, + applicationToken: response.value + ) self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: self, transition: .show) } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift index 749e52ab3..5361b3117 100644 --- a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift @@ -20,15 +20,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency { var viewModel: MastodonRegisterViewModel! let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer - let stackViewTopDistance: CGFloat = 16 - - var keyboardFrame: CGRect! var scrollview: UIScrollView = { let scrollview = UIScrollView() scrollview.showsVerticalScrollIndicator = false scrollview.translatesAutoresizingMaskIntoConstraints = false + scrollview.keyboardDismissMode = .interactive return scrollview }() @@ -226,21 +224,21 @@ extension MastodonRegisterViewController { view.addSubview(scrollview) NSLayoutConstraint.activate([ scrollview.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), - scrollview.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), - view.trailingAnchor.constraint(equalTo: scrollview.frameLayoutGuide.trailingAnchor, constant: 20), + scrollview.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + view.readableContentGuide.trailingAnchor.constraint(equalTo: scrollview.frameLayoutGuide.trailingAnchor), scrollview.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor), + scrollview.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollview.contentLayoutGuide.widthAnchor), ]) - + // stackview scrollview.addSubview(stackView) stackView.translatesAutoresizingMaskIntoConstraints = false - let bottomEdgeLayoutConstraint: NSLayoutConstraint = scrollview.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor) NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: scrollview.contentLayoutGuide.topAnchor, constant: stackViewTopDistance), stackView.leadingAnchor.constraint(equalTo: scrollview.contentLayoutGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: scrollview.contentLayoutGuide.trailingAnchor), stackView.widthAnchor.constraint(equalTo: scrollview.frameLayoutGuide.widthAnchor), - bottomEdgeLayoutConstraint, + scrollview.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), ]) // photoview @@ -268,7 +266,7 @@ extension MastodonRegisterViewController { plusIcon.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor), plusIcon.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor), ]) - + // textfield NSLayoutConstraint.activate([ usernameTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), @@ -280,34 +278,49 @@ extension MastodonRegisterViewController { // password stackView.setCustomSpacing(6, after: passwordTextField) stackView.setCustomSpacing(32, after: passwordCheckLabel) - + // button signUpButton.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(signUpButton) NSLayoutConstraint.activate([ signUpButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), ]) - + signUpActivityIndicatorView.translatesAutoresizingMaskIntoConstraints = false scrollview.addSubview(signUpActivityIndicatorView) NSLayoutConstraint.activate([ signUpActivityIndicatorView.centerXAnchor.constraint(equalTo: signUpButton.centerXAnchor), signUpActivityIndicatorView.centerYAnchor.constraint(equalTo: signUpButton.centerYAnchor), ]) - - NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification, object: nil) - .sink { [weak self] notification in - guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { - return - } - self?.keyboardFrame = endFrame - UIView.animate(withDuration: 0.3) { - bottomEdgeLayoutConstraint.constant = UIScreen.main.bounds.height - endFrame.origin.y + 26 - self?.view.layoutIfNeeded() - } + + Publishers.CombineLatest3( + KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), + KeyboardResponderService.shared.state.eraseToAnyPublisher(), + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() + ) + .sink(receiveValue: { [weak self] isShow, state, endFrame in + guard let self = self else { return } + + guard isShow, state == .dock else { + self.scrollview.contentInset.bottom = 0.0 + self.scrollview.verticalScrollIndicatorInsets.bottom = 0.0 + return } - .store(in: &disposeBag) - + + // isShow AND dock state + let contentFrame = self.view.convert(self.scrollview.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + self.scrollview.contentInset.bottom = 0.0 + self.scrollview.verticalScrollIndicatorInsets.bottom = 0.0 + return + } + + self.scrollview.contentInset.bottom = padding + 16 + self.scrollview.verticalScrollIndicatorInsets.bottom = padding + 16 + }) + .store(in: &disposeBag) + viewModel.isRegistering .receive(on: DispatchQueue.main) .sink { [weak self] isRegistering in @@ -317,7 +330,7 @@ extension MastodonRegisterViewController { self.signUpButton.isEnabled = !isRegistering } .store(in: &disposeBag) - + viewModel.isUsernameValid .receive(on: DispatchQueue.main) .sink { [weak self] isValid in @@ -359,7 +372,7 @@ extension MastodonRegisterViewController { self.signUpButton.isEnabled = isUsernameValid ?? false && isDisplaynameValid ?? false && isEmailValid ?? false && isPasswordValid ?? false } .store(in: &disposeBag) - + viewModel.error .compactMap { $0 } .receive(on: DispatchQueue.main) @@ -375,6 +388,7 @@ extension MastodonRegisterViewController { ) } .store(in: &disposeBag) + NotificationCenter.default .publisher(for: UITextField.textDidChangeNotification, object: passwordTextField) .receive(on: DispatchQueue.main) @@ -385,20 +399,26 @@ extension MastodonRegisterViewController { self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validations.0, oneNumber: validations.1, oneSpecialCharacter: validations.2) } .store(in: &disposeBag) - + signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } } extension MastodonRegisterViewController: UITextFieldDelegate { func textFieldDidBeginEditing(_ textField: UITextField) { - var bottomOffsetY: CGFloat = textField.frame.origin.y + textField.frame.height - scrollview.frame.height + keyboardFrame.size.height + stackViewTopDistance - if textField == passwordTextField { - bottomOffsetY += passwordCheckLabel.frame.height - } - - if bottomOffsetY > 0 { - scrollview.setContentOffset(CGPoint(x: 0, y: bottomOffsetY), animated: true) + // align to password label when overlap + if textField === passwordTextField, + KeyboardResponderService.shared.isShow.value, + KeyboardResponderService.shared.state.value == .dock { + let endFrame = KeyboardResponderService.shared.endFrame.value + let contentFrame = self.scrollview.convert(self.passwordCheckLabel.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + if padding > 0 { + let contentOffsetY = scrollview.contentOffset.y + DispatchQueue.main.async { + self.scrollview.setContentOffset(CGPoint(x: 0, y: contentOffsetY + padding + 16.0), animated: true) + } + } } } @@ -454,7 +474,10 @@ extension MastodonRegisterViewController { @objc private func signUpButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - if !validateAllTextField() { + guard validateAllTextField(), + let username = viewModel.username.value, + let email = viewModel.email.value, + let password = viewModel.password.value else { return } @@ -463,9 +486,9 @@ extension MastodonRegisterViewController { let query = Mastodon.API.Account.RegisterQuery( reason: nil, - username: viewModel.username.value!, - email: viewModel.email.value!, - password: viewModel.password.value!, + username: username, + email: email, + password: password, agreement: true, // TODO: locale: "en" // TODO: ) diff --git a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewModel.swift index 590f2c0d8..944cb9b0c 100644 --- a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewModel.swift @@ -15,7 +15,9 @@ final class MastodonRegisterViewModel { // input let domain: String + let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let applicationToken: Mastodon.Entity.Token + let isRegistering = CurrentValueSubject(false) let username = CurrentValueSubject(nil) let displayname = CurrentValueSubject(nil) @@ -32,8 +34,13 @@ final class MastodonRegisterViewModel { let error = CurrentValueSubject(nil) - init(domain: String, applicationToken: Mastodon.Entity.Token) { + init( + domain: String, + authenticateInfo: AuthenticationViewModel.AuthenticateInfo, + applicationToken: Mastodon.Entity.Token + ) { self.domain = domain + self.authenticateInfo = authenticateInfo self.applicationToken = applicationToken self.applicationAuthorization = Mastodon.API.OAuth.Authorization(accessToken: applicationToken.accessToken) diff --git a/Mastodon/Service/KeyboardResponderService.swift b/Mastodon/Service/KeyboardResponderService.swift new file mode 100644 index 000000000..1711680a0 --- /dev/null +++ b/Mastodon/Service/KeyboardResponderService.swift @@ -0,0 +1,87 @@ +// +// KeyboardResponderService.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-2-20. +// + +import UIKit +import Combine + +final class KeyboardResponderService { + + var disposeBag = Set() + + // MARK: - Singleton + public static let shared = KeyboardResponderService() + + // output + let isShow = CurrentValueSubject(false) + let state = CurrentValueSubject(.none) + let endFrame = CurrentValueSubject(.zero) + + private init() { + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification, object: nil) + .sink { notification in + self.isShow.value = true + self.updateInternalStatus(notification: notification) + } + .store(in: &disposeBag) + + NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification, object: nil) + .sink { notification in + self.isShow.value = false + self.updateInternalStatus(notification: notification) + } + .store(in: &disposeBag) + + NotificationCenter.default.publisher(for: UIResponder.keyboardDidChangeFrameNotification, object: nil) + .sink { notification in + self.updateInternalStatus(notification: notification) + } + .store(in: &disposeBag) + } + +} + +extension KeyboardResponderService { + + private func updateInternalStatus(notification: Notification) { + guard let isLocal = notification.userInfo?[UIWindow.keyboardIsLocalUserInfoKey] as? Bool, + let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return + } + + self.endFrame.value = endFrame + + guard isLocal else { + self.state.value = .notLocal + return + } + + // check if floating + guard endFrame.width == UIScreen.main.bounds.width else { + self.state.value = .floating + return + } + + // check if undock | split + let dockMinY = UIScreen.main.bounds.height - endFrame.height + if endFrame.minY < dockMinY { + self.state.value = .notDock + } else { + self.state.value = .dock + } + } + +} + +extension KeyboardResponderService { + enum KeyboardState { + case none + case notLocal + case notDock // undock | split + case floating // iPhone size floating + case dock + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift index 94d063c40..ee649b43e 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift @@ -34,3 +34,27 @@ extension Mastodon.API { } } + +extension Mastodon.API.Error: LocalizedError { + + public var errorDescription: String? { + guard let mastodonError = mastodonError else { + return nil + } + switch mastodonError { + case .generic(let error): + return error.error + } + } + + public var failureReason: String? { + guard let mastodonError = mastodonError else { + return nil + } + switch mastodonError { + case .generic(let error): + return error.errorDescription + } + } + +}