From 028f3df2d6a95763b358edc181442cbd722806a3 Mon Sep 17 00:00:00 2001 From: Miltiadis Vasilakis Date: Tue, 24 Oct 2023 20:01:17 +0300 Subject: [PATCH] Improve master container bottom additional safe area inset --- .../Views/CollectionsViewController.swift | 2 + .../Views/LibrariesViewController.swift | 2 + .../MasterContainerViewController.swift | 158 +++++++++++------- 3 files changed, 97 insertions(+), 65 deletions(-) diff --git a/Zotero/Scenes/Master/Collections/Views/CollectionsViewController.swift b/Zotero/Scenes/Master/Collections/Views/CollectionsViewController.swift index 28489e622..dfe53423f 100644 --- a/Zotero/Scenes/Master/Collections/Views/CollectionsViewController.swift +++ b/Zotero/Scenes/Master/Collections/Views/CollectionsViewController.swift @@ -192,3 +192,5 @@ extension CollectionsViewController: UIContextMenuInteractionDelegate { }) } } + +extension CollectionsViewController: BottomSheetObserver { } diff --git a/Zotero/Scenes/Master/Libraries/Views/LibrariesViewController.swift b/Zotero/Scenes/Master/Libraries/Views/LibrariesViewController.swift index 6528e7514..a389726ff 100644 --- a/Zotero/Scenes/Master/Libraries/Views/LibrariesViewController.swift +++ b/Zotero/Scenes/Master/Libraries/Views/LibrariesViewController.swift @@ -207,3 +207,5 @@ extension LibrariesViewController: UITableViewDataSource, UITableViewDelegate { } } } + +extension LibrariesViewController: BottomSheetObserver { } diff --git a/Zotero/Scenes/Master/MasterContainerViewController.swift b/Zotero/Scenes/Master/MasterContainerViewController.swift index 280a4a17c..e11b9f084 100644 --- a/Zotero/Scenes/Master/MasterContainerViewController.swift +++ b/Zotero/Scenes/Master/MasterContainerViewController.swift @@ -16,37 +16,48 @@ protocol DraggableViewController: UIViewController { func disablePanning() } +protocol BottomSheetObserver: UIViewController { + func bottomSheetUpdated(hidden: Bool, containerHeight: CGFloat, topOffset: CGFloat) +} + +extension BottomSheetObserver { + func bottomSheetUpdated(hidden: Bool, containerHeight: CGFloat, topOffset: CGFloat) { + let bottomInset: CGFloat = hidden ? .zero : (containerHeight - topOffset) + additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) + } +} + final class MasterContainerViewController: UINavigationController { enum BottomPosition { case mostlyVisible case `default` case hidden case custom(CGFloat) - + func topOffset(availableHeight: CGFloat) -> CGFloat { switch self { case .mostlyVisible: return 202 - + case .default: return availableHeight * 0.6 - + case .hidden: return availableHeight - MasterContainerViewController.bottomControllerHandleHeight - + case .custom(let offset): return availableHeight - offset < MasterContainerViewController.minVisibleBottomHeight ? MasterContainerViewController.minVisibleBottomHeight : offset } } } - + private static let dragHandleTopOffset: CGFloat = 11 private static let bottomControllerHandleHeight: CGFloat = 27 private static let bottomContainerTappableHeight: CGFloat = 35 private static let bottomContainerDraggableHeight: CGFloat = 55 private static let minVisibleBottomHeight: CGFloat = 200 private let disposeBag: DisposeBag - + lazy var bottomController: DraggableViewController? = { return coordinatorDelegate?.createBottomController() }() @@ -60,7 +71,7 @@ final class MasterContainerViewController: UINavigationController { // Used to calculate position and velocity when dragging private var initialBottomMinY: CGFloat? private var keyboardHeight: CGFloat = 0 - + weak var coordinatorDelegate: MasterContainerCoordinatorDelegate? init() { @@ -68,56 +79,57 @@ final class MasterContainerViewController: UINavigationController { self.disposeBag = DisposeBag() super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: - Lifecycle - + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear + delegate = self setupView() setupKeyboardObserving() setBottomSheet(hidden: true) func setupView() { guard let bottomController else { return } - + let bottomContainer = UIView() bottomContainer.translatesAutoresizingMaskIntoConstraints = false bottomContainer.layer.masksToBounds = true bottomContainer.backgroundColor = .systemBackground view.addSubview(bottomContainer) - + let handleBackground = UIView() handleBackground.translatesAutoresizingMaskIntoConstraints = false handleBackground.backgroundColor = .systemBackground bottomContainer.addSubview(handleBackground) - + let dragIcon = UIImageView(image: Asset.Images.dragHandle.image.withRenderingMode(.alwaysTemplate)) dragIcon.translatesAutoresizingMaskIntoConstraints = false dragIcon.tintColor = .gray.withAlphaComponent(0.6) bottomContainer.addSubview(dragIcon) - + let separator = UIView() separator.translatesAutoresizingMaskIntoConstraints = false separator.backgroundColor = .opaqueSeparator bottomContainer.addSubview(separator) - + bottomController.view.translatesAutoresizingMaskIntoConstraints = false // Since the instance keeps a strong reference to the bottomController, its view is simply added as a subview. // Adding bottomController as a child view controller, would mess up the navigation stack. bottomContainer.addSubview(bottomController.view) - + let bottomControllerHeight = bottomController.view.heightAnchor.constraint(greaterThanOrEqualToConstant: 100) bottomControllerHeight.priority = .required let bottomControllerBottom = bottomController.view.bottomAnchor.constraint(equalTo: bottomContainer.bottomAnchor) bottomControllerBottom.priority = UILayoutPriority(999) let bottomYConstraint = bottomContainer.topAnchor.constraint(equalTo: view.topAnchor) let bottomContainerBottomConstraint = view.bottomAnchor.constraint(equalTo: bottomContainer.bottomAnchor) - + // bottom container contains from top to bottom: // --- handle background (drag icon) - bottom controller view // \- separator @@ -146,11 +158,11 @@ final class MasterContainerViewController: UINavigationController { bottomControllerHeight, bottomControllerBottom ]) - + self.bottomContainer = bottomContainer self.bottomYConstraint = bottomYConstraint self.bottomContainerBottomConstraint = bottomContainerBottomConstraint - + let bottomPanRecognizer = UIPanGestureRecognizer() bottomPanRecognizer.delegate = self bottomPanRecognizer.rx.event @@ -158,7 +170,7 @@ final class MasterContainerViewController: UINavigationController { toolbarDidPan(recognizer: recognizer) }) .disposed(by: disposeBag) - + let tapRecognizer = UITapGestureRecognizer() tapRecognizer.delegate = self tapRecognizer.require(toFail: bottomPanRecognizer) @@ -167,19 +179,19 @@ final class MasterContainerViewController: UINavigationController { toggleBottomPosition() }) .disposed(by: disposeBag) - + bottomContainer.addGestureRecognizer(bottomPanRecognizer) bottomContainer.addGestureRecognizer(tapRecognizer) - + func toolbarDidPan(recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .began: initialBottomMinY = bottomContainer.frame.minY bottomController.disablePanning() - + case .changed: guard let initialBottomMinY else { return } - + let translation = recognizer.translation(in: self.view) let availableHeight = view.frame.height var minY = initialBottomMinY + translation.y @@ -190,31 +202,31 @@ final class MasterContainerViewController: UINavigationController { } else if minY > hiddenTopOffset { minY = hiddenTopOffset } - + bottomYConstraint.constant = minY view.layoutIfNeeded() - + case .ended, .failed: let availableHeight = view.frame.height - keyboardHeight let dragVelocity = recognizer.velocity(in: view) let newPosition = position(fromYPos: bottomYConstraint.constant, containerHeight: availableHeight, velocity: dragVelocity) let velocity = velocity(from: dragVelocity, currentYPos: bottomYConstraint.constant, position: newPosition, availableHeight: availableHeight) - - set(bottomPosition: newPosition, containerHeight: availableHeight) - + + set(bottomPosition: newPosition, containerHeight: view.frame.height, keyboardHeight: keyboardHeight) + switch newPosition { case .custom: view.layoutIfNeeded() - + case .mostlyVisible, .default, .hidden: UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: velocity, options: [.curveEaseOut], animations: { self.view.layoutIfNeeded() }) } - + initialBottomMinY = nil bottomController.enablePanning() - + case .cancelled, .possible: break @@ -233,11 +245,11 @@ final class MasterContainerViewController: UINavigationController { return velocity.y > 0 ? .default : .mostlyVisible } } - + if yPos > (containerHeight - Self.minVisibleBottomHeight) { return velocity.y > 0 ? .hidden : .default } - + return .custom(yPos) } @@ -251,21 +263,21 @@ final class MasterContainerViewController: UINavigationController { case .hidden: set(bottomPosition: previousBottomPosition ?? .default) previousBottomPosition = nil - + default: previousBottomPosition = bottomPosition - + if let controller = bottomController as? TagFilterViewController, controller.searchBar.isFirstResponder { // If tag picker search bar is first responder and tag picker was toggled to hide, we should deselect the search bar bottomPosition = .hidden - // Don't need to `set(bottomPosition:containerHeight:)` manually here, resigning search bar will send keyboard notifications and the UI will update there. + // Don't need to `set(bottomPosition:containerHeight:keyboardHeight:)` manually here, resigning search bar will send keyboard notifications and the UI will update there. controller.searchBar.resignFirstResponder() return } - + set(bottomPosition: .hidden) } - + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut, animations: { self.view.layoutIfNeeded() }) @@ -282,7 +294,7 @@ final class MasterContainerViewController: UINavigationController { } }) .disposed(by: disposeBag) - + NotificationCenter.default .keyboardWillHide .observe(on: MainScheduler.instance) @@ -295,7 +307,7 @@ final class MasterContainerViewController: UINavigationController { func setupKeyboard(with keyboardData: KeyboardData) { keyboardHeight = keyboardData.visibleHeight - + updateBottomPosition() bottomContainerBottomConstraint?.constant = keyboardData.visibleHeight UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { @@ -304,10 +316,10 @@ final class MasterContainerViewController: UINavigationController { } } } - + override func viewIsAppearing(_ animated: Bool) { super.viewIsAppearing(animated) - + updateBottomPosition() if let splitViewController { // Split view controller collapsed status when the app launches is correct here, so it's used to show/hide bottom sheet for the first appearance. @@ -315,23 +327,27 @@ final class MasterContainerViewController: UINavigationController { setBottomSheet(hidden: splitViewController.isCollapsed) } } - + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) - - let visibleHeight = size.height - keyboardHeight - set(bottomPosition: bottomPosition, containerHeight: visibleHeight) - + + set(bottomPosition: bottomPosition, containerHeight: size.height, keyboardHeight: keyboardHeight) + coordinator.animate(alongsideTransition: { _ in self.view.layoutIfNeeded() }, completion: nil) + coordinator.animate { _ in + self.view.layoutIfNeeded() + } completion: { _ in + self.updateBottomPosition() + } } - + override func collapseSecondaryViewController(_ secondaryViewController: UIViewController, for splitViewController: UISplitViewController) { setBottomSheet(hidden: true) super.collapseSecondaryViewController(secondaryViewController, for: splitViewController) } - + override func separateSecondaryViewController(for splitViewController: UISplitViewController) -> UIViewController? { setBottomSheet(hidden: false) guard topViewController?.isKind(of: UINavigationController.self) == true else { @@ -341,23 +357,29 @@ final class MasterContainerViewController: UINavigationController { } return super.separateSecondaryViewController(for: splitViewController) } - - // MARK: - Bottom panning - + + // MARK: - Bottom Panning + var isBottomSheetHidden: Bool { + bottomContainer?.isHidden ?? true + } + private func setBottomSheet(hidden: Bool) { - guard let container = bottomContainer else { return } - container.isHidden = hidden - additionalSafeAreaInsets = hidden ? .zero : UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0) + bottomContainer?.isHidden = hidden + updateBottomPosition() } - - private func set(bottomPosition: BottomPosition, containerHeight: CGFloat) { - bottomYConstraint?.constant = bottomPosition.topOffset(availableHeight: containerHeight) + + private func set(bottomPosition: BottomPosition, containerHeight: CGFloat, keyboardHeight: CGFloat) { + let availableHeight = containerHeight - keyboardHeight + let topOffset = bottomPosition.topOffset(availableHeight: availableHeight) + bottomYConstraint?.constant = topOffset self.bottomPosition = bottomPosition + for viewController in viewControllers { + (viewController as? BottomSheetObserver)?.bottomSheetUpdated(hidden: isBottomSheetHidden, containerHeight: containerHeight, topOffset: topOffset) + } } private func set(bottomPosition: BottomPosition) { - let availableHeight = view.frame.height - keyboardHeight - set(bottomPosition: bottomPosition, containerHeight: availableHeight) + set(bottomPosition: bottomPosition, containerHeight: view.frame.height, keyboardHeight: keyboardHeight) } private func updateBottomPosition() { @@ -370,18 +392,18 @@ extension MasterContainerViewController: UIGestureRecognizerDelegate { guard let bottomContainer else { return false } let location = gestureRecognizer.location(in: bottomContainer) - + if gestureRecognizer is UITapGestureRecognizer { return location.y <= Self.bottomContainerTappableHeight } - + if gestureRecognizer is UIPanGestureRecognizer { return location.y <= Self.bottomContainerDraggableHeight } - + return false } - + // func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { // guard let panRecognizer = gestureRecognizer as? UIPanGestureRecognizer, let collectionView = otherGestureRecognizer.view as? UICollectionView else { return true } // @@ -396,3 +418,9 @@ extension MasterContainerViewController: UIGestureRecognizerDelegate { // return false // } } + +extension MasterContainerViewController: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + updateBottomPosition() + } +}