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

fix: Improve activity presenter transition #1600 #1821

Merged
merged 1 commit into from
Dec 27, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ class TagSettingsPresenter: NSObject, TagSettingsModuleInput {
private var appDidBecomeActiveToken: NSObjectProtocol?
private var alertDidChangeToken: NSObjectProtocol?
private var backgroundToken: NSObjectProtocol?
private var cloudRequestStateToken: NSObjectProtocol?
private var mutedTillTimer: Timer?
private var exportFileUrl: URL?
private var previousAdvertisementSequence: Int?
Expand Down Expand Up @@ -108,8 +107,10 @@ class TagSettingsPresenter: NSObject, TagSettingsModuleInput {
appDidBecomeActiveToken?.invalidate()
alertDidChangeToken?.invalidate()
backgroundToken?.invalidate()
cloudRequestStateToken?.invalidate()
timer?.invalidate()
RuuviCloudRequestStateObserverManager
.shared
.stopObserving(for: ruuviTag.macId?.value)
NotificationCenter.default.removeObserver(self)
}

Expand Down Expand Up @@ -1143,27 +1144,17 @@ extension TagSettingsPresenter {
}

private func startObservingCloudRequestState() {
cloudRequestStateToken?.invalidate()
cloudRequestStateToken = nil

cloudRequestStateToken = NotificationCenter
.default
.addObserver(
forName: .RuuviCloudRequestStateDidChange,
object: nil,
queue: .main,
using: { [weak self] notification in
guard let self,
let userInfo = notification.userInfo,
let macId = userInfo[RuuviCloudRequestStateKey.macId] as? String,
macId == self.ruuviTag.macId?.value,
let state = userInfo[RuuviCloudRequestStateKey.state] as? RuuviCloudRequestStateType
else {
return
}
self.presentActivityIndicator(with: state)
}
)
guard let macId = ruuviTag.macId?.value else { return }
// Stop if already observing
RuuviCloudRequestStateObserverManager
.shared
.stopObserving(for: macId)

RuuviCloudRequestStateObserverManager
.shared
.startObserving(for: macId) { [weak self] state in
self?.presentActivityIndicator(with: state)
}
}

private func reloadMutedTill() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,54 +1,146 @@
import UIKit

public final class ActivityPresenterRuuviLogo: ActivityPresenter {
let minAnimationTime: CFTimeInterval = 1.5
var startTime: CFTimeInterval?
let window = UIWindow(frame: UIScreen.main.bounds)
let activityPresenterViewProvider: ActivityPresenterViewProvider
let activityPresenterViewController: UIViewController
let stateHolder = ActivityPresenterStateHolder()
public final class ActivityPresenterRuuviLogo {
// Minimum duration to keep the activity indicator on the screen
private let minAnimationTime: CFTimeInterval = 1.5
// Animation duration for presenting and dismissing the activity indicator.
private let animationDuration: CGFloat = 0.5
// Vertical(top/bottom) padding for activity indicator depending on position.
private let verticalPadding: CGFloat = 8

weak var appWindow: UIWindow?
private let activityPresenterViewProvider: ActivityPresenterViewProvider
private let activityPresenterViewController: UIViewController
private let stateHolder = ActivityPresenterStateHolder()

private var activityPresenterPosition: ActivityPresenterPosition = .bottom
private var startTime: CFTimeInterval?

public init() {
activityPresenterViewProvider = ActivityPresenterViewProvider(stateHolder: stateHolder)
activityPresenterViewController = activityPresenterViewProvider.makeViewController()
activityPresenterViewController.view.backgroundColor = .clear
window.windowLevel = .normal
activityPresenterViewController.view.translatesAutoresizingMaskIntoConstraints = false
window.rootViewController = activityPresenterViewController
}
}

public extension ActivityPresenterRuuviLogo {
func setPosition(_ position: ActivityPresenterPosition) {
extension ActivityPresenterRuuviLogo: ActivityPresenter {
public func show(
with state: ActivityPresenterState,
atPosition position: ActivityPresenterPosition
) {
activityPresenterPosition = position
activityPresenterViewProvider.updatePosition(position)
}

func show(with state: ActivityPresenterState) {
startTime = CFAbsoluteTimeGetCurrent()
appWindow = UIWindow.key
window.makeKeyAndVisible()
window.layoutIfNeeded()

guard let topController = RuuviPresenterHelper.topViewController(),
let toastView = activityPresenterViewController.view else {
return
}

topController.view.addSubview(toastView)
setupConstraints(for: toastView, in: topController)
animateActivityViewPresentation(toastView, atPosition: position)

activityPresenterViewProvider.updateState(state)
}

func update(with state: ActivityPresenterState) {
// Reset start time with state update since we want to show
// latest state message before dismissing the presenter.
public func update(with state: ActivityPresenterState) {
startTime = CFAbsoluteTimeGetCurrent()
activityPresenterViewProvider.updateState(state)
}

func dismiss(immediately: Bool) {
public func dismiss(immediately: Bool) {
let executionTime = CFAbsoluteTimeGetCurrent() - (startTime ?? 0)
let additionalWaitTime = immediately ? 0 :
executionTime < minAnimationTime ? (minAnimationTime - executionTime) : 0
DispatchQueue.main.asyncAfter(deadline: .now() + additionalWaitTime) { [weak self] in
self?.window.isHidden = true
self?.appWindow?.makeKeyAndVisible()
self?.appWindow = nil
let additionalWaitTime = immediately ? 0 : max(minAnimationTime - executionTime, 0)

guard let toastView = activityPresenterViewController.view else {
return
}

DispatchQueue.main.asyncAfter(
deadline: .now() + additionalWaitTime
) { [weak self] in
self?.animateActivityViewDismissal(toastView)
}
}
}

extension ActivityPresenterRuuviLogo {
private func setupConstraints(
for view: UIView,
in viewController: UIViewController
) {
view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(
equalTo: viewController.view.leadingAnchor
),
view.trailingAnchor.constraint(
equalTo: viewController.view.trailingAnchor
),
view.topAnchor.constraint(
equalTo: viewController.view.topAnchor
),
view.bottomAnchor.constraint(
equalTo: viewController.view.bottomAnchor
),
])
}

private func animateActivityViewPresentation(
_ view: UIView,
atPosition position: ActivityPresenterPosition
) {
UIView.animate(
withDuration: animationDuration,
delay: 0,
options: [.curveEaseOut]
) { [weak self] in
guard let self = self else { return }
switch position {
case .top:
view.transform = CGAffineTransform(
translationX: 0,
y: self.safeAreaInsets().top + self.verticalPadding
)
case .bottom:
view.transform = CGAffineTransform(
translationX: 0,
y: -(self.safeAreaInsets().bottom + self.verticalPadding)
)
case .center:
view.transform = .identity
}
}
}

private func animateActivityViewDismissal(_ view: UIView) {
UIView.animate(
withDuration: animationDuration,
delay: 0,
options: [.curveEaseIn]
) { [weak self] in
guard let self = self else { return }
switch self.activityPresenterPosition {
case .top:
view.transform = CGAffineTransform(
translationX: 0,
y: -(view.frame.height + verticalPadding)
)
case .bottom:
view.transform = CGAffineTransform(
translationX: 0,
y: view.frame.height + verticalPadding
)
case .center:
break
}
} completion: { [weak self] _ in
view.removeFromSuperview()
self?.activityPresenterViewProvider.updateState(.dismiss)
}
}

private func safeAreaInsets() -> UIEdgeInsets {
RuuviPresenterHelper.topViewController()?.view.safeAreaInsets ?? .zero
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import Foundation

public protocol ActivityPresenter {
func setPosition(_ position: ActivityPresenterPosition)
func show(with state: ActivityPresenterState)
func show(
with state: ActivityPresenterState,
atPosition position: ActivityPresenterPosition
)
func update(with state: ActivityPresenterState)
func dismiss(immediately: Bool)
}

public extension ActivityPresenter {
func show(
with state: ActivityPresenterState,
atPosition position: ActivityPresenterPosition = .bottom
) {
show(with: state, atPosition: position)
}

func dismiss(immediately: Bool = false) {
dismiss(immediately: immediately)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public final class ErrorPresenterAlert: ErrorPresenter {
let feedback = UINotificationFeedbackGenerator()
feedback.notificationOccurred(.error)
feedback.prepare()
UIApplication.shared.topViewController()?.present(alert, animated: true)
RuuviPresenterHelper.topViewController()?.present(alert, animated: true)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public final class PermissionPresenterAlert: PermissionPresenter {
}

private func presentAlert(with message: String) {
guard let viewController = UIApplication.shared.topViewController() else { return }
guard let viewController = RuuviPresenterHelper.topViewController() else { return }
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
let cancel = UIAlertAction(title: RuuviLocalization.cancel, style: .cancel, handler: nil)
let actionTitle = RuuviLocalization.PermissionPresenter.settings
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import UIKit

struct RuuviPresenterHelper {
public static func topViewController() -> UIViewController? {
if var topController = keyWindow()?.rootViewController {
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
return topController
}
return nil
}

private static func keyWindow() -> UIWindow? {
return UIApplication.shared.windows.first(where: { $0.isKeyWindow })
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

public class RuuviCloudRequestStateObserverManager {
public static let shared = RuuviCloudRequestStateObserverManager()
private var cloudRequestStateTokens: [String: NSObjectProtocol] = [:]

private init() {}

public func startObserving(
for macId: String?,
callback: @escaping (RuuviCloudRequestStateType) -> Void
) {
// Ensures no duplicate observers for the same macId
guard let macId = macId else { return }
stopObserving(for: macId)

let token = NotificationCenter.default.addObserver(
forName: .RuuviCloudRequestStateDidChange,
object: nil,
queue: .main,
using: { notification in
guard let userInfo = notification.userInfo,
let observedMacId = userInfo[RuuviCloudRequestStateKey.macId] as? String,
observedMacId == macId,
let state = userInfo[RuuviCloudRequestStateKey.state] as? RuuviCloudRequestStateType else {
return
}
callback(state)
}
)

cloudRequestStateTokens[macId] = token
}

public func stopObserving(for macId: String?) {
guard let macId = macId else { return }
if let token = cloudRequestStateTokens[macId] {
NotificationCenter.default.removeObserver(token)
cloudRequestStateTokens[macId] = nil
}
}

public func stopAllObservers() {
for (macId, _) in cloudRequestStateTokens {
stopObserving(for: macId)
}
}
}