// // Copyright (c) 2023 Touch Instinct // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the Software), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. // import TISwiftUtils import TIUIElements import TIUIKitCore import UIKit open class BaseModalViewController: BaseInitializableViewController, PanModalPresentable { // MARK: - Public Properties public let dragView = DragView() public let headerView = ModalHeaderView() private(set) public lazy var contentView = createContentView() private(set) public lazy var footerView = createFooterView() public var dragViewConstraints: SubviewConstraints? public var headerViewConstraints: SubviewConstraints? public var contentViewConstraints: SubviewConstraints? public var footerViewConstraints: SubviewConstraints? public var keyboardDidShownObserver: NSObjectProtocol? public var keyboardDidHiddenObserver: NSObjectProtocol? // MARK: - Modal View Controller Configuration open var viewControllerAppearance: BaseAppearance { .init(backgroundColor: .white) } open var panScrollable: UIScrollView? { contentView as? UIScrollView } open var presentationDetents: [ModalViewPresentationDetent] { [.maxHeight] } open var headerViewHeight: CGFloat { let dragViewHeight = getHeight(of: dragView) let headerViewHeight = getHeight(of: headerView) let dragViewVerticalInsets = getDragViewVerticalInsets() let headerViewVerticalInsets = getHeaderViewVerticalInsets() return dragViewHeight + headerViewHeight + dragViewVerticalInsets + headerViewVerticalInsets } open var longFormHeight: PanModalHeight { let detents = getSortedDetents() return detents.max()?.panModalHeight(headerHeight: headerViewHeight) ?? .maxHeight } open var mediumFormHeight: PanModalHeight { let detents = getSortedDetents() if detents.count > 1 { return detents[1].panModalHeight(headerHeight: headerViewHeight) } return detents.first?.panModalHeight(headerHeight: headerViewHeight) ?? .maxHeight } open var shortFormHeight: PanModalHeight { let detents = getSortedDetents() return detents.min()?.panModalHeight(headerHeight: headerViewHeight) ?? .maxHeight } // MARK: - Life Cycle deinit { if let keyboardDidShownObserver { NotificationCenter.default.removeObserver(keyboardDidShownObserver) } if let keyboardDidHiddenObserver { NotificationCenter.default.removeObserver(keyboardDidHiddenObserver) } } open override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() adjustScrollViewIfNeeded() } // MARK: - BaseInitializableViewController open override func addViews() { super.addViews() view.addSubviews(dragView, headerView, contentView, footerView) } open override func configureLayout() { super.configureLayout() configureDragViewLayout() configureHeaderViewLayout() configureContentViewLayout() configureFooterViewLayout() } open override func bindViews() { super.bindViews() keyboardDidShownObserver = NotificationCenter.default .addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: .main) { [weak self] notification in self?.configureLayoutForKeyboard(notification, isKeyboardHidden: false) } keyboardDidHiddenObserver = NotificationCenter.default .addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: .main) { [weak self] notification in self?.configureLayoutForKeyboard(notification, isKeyboardHidden: true) } } open override func configureAppearance() { super.configureAppearance() view.configureUIView(appearance: viewControllerAppearance) configureDragViewAppearance() configureHeaderViewAppearance() configureFooterViewAppearance() } // MARK: - Open Methods open func createContentView() -> ContentView { ContentView() } open func createFooterView() -> ModalFooterView { ModalFooterView() } open func configureLayoutForKeyboard(_ notification: Notification, isKeyboardHidden: Bool) { guard let height = getKeyboardHeight(notification) else { return } if let panScrollable { if isKeyboardHidden { adjustScrollViewIfNeeded() } else { panScrollable.contentInset = .vertical(bottom: height) } } else { contentViewConstraints?.bottomConstraint?.constant = isKeyboardHidden ? .zero : height } } open func adjustScrollViewIfNeeded() { guard let panScrollable, case let .presented(appearance) = viewControllerAppearance.footerViewState else { return } let verticalInsets = appearance.layout.insets.vertical let subviewVerticalInsets = appearance.subviewAppearance.layout.insets.vertical let totalHeight = getHeight(of: footerView) + verticalInsets + subviewVerticalInsets panScrollable.contentInset = .vertical(bottom: totalHeight) } // MARK: - Private Methods private func configureDragViewLayout() { guard case let .presented(appearance) = viewControllerAppearance.dragViewState else { return } dragView.translatesAutoresizingMaskIntoConstraints = false let dragViewConstraints = SubviewConstraints( centerXConstraint: dragView.centerXAnchor.constraint(equalTo: view.centerXAnchor), topConstraint: dragView.topAnchor.constraint(equalTo: view.topAnchor), widthConstraint: dragView.widthAnchor.constraint(equalToConstant: .zero), heightConstraint: dragView.heightAnchor.constraint(equalToConstant: .zero)) self.dragViewConstraints = dragViewConstraints UIView.configure(layout: appearance.layout, constraints: dragViewConstraints) NSLayoutConstraint.activate(dragViewConstraints.centerConstraints) } private func configureHeaderViewLayout() { guard case let .presented(appearance) = viewControllerAppearance.headerViewState else { return } headerView.translatesAutoresizingMaskIntoConstraints = false let isTopView = dragViewConstraints == nil let headerViewConstraints = SubviewConstraints( leadingConstraint: headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), topConstraint: headerView.topAnchor.constraint(equalTo: isTopView ? view.topAnchor : dragView.bottomAnchor), trailingConstraint: headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), widthConstraint: headerView.widthAnchor.constraint(equalToConstant: .zero), heightConstraint: headerView.heightAnchor.constraint(equalToConstant: .zero)) self.headerViewConstraints = headerViewConstraints UIView.configure(layout: appearance.layout, constraints: headerViewConstraints) } private func configureContentViewLayout() { contentView.translatesAutoresizingMaskIntoConstraints = false var topView: UIView if headerViewConstraints == nil { if dragViewConstraints == nil { topView = view } else { topView = dragView } } else { topView = headerView } let contentViewConstraints = SubviewConstraints( leadingConstraint: contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), topConstraint: contentView.topAnchor.constraint(equalTo: topView.bottomAnchor), trailingConstraint: contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), bottomConstraint: contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor)) self.contentViewConstraints = contentViewConstraints NSLayoutConstraint.activate(contentViewConstraints.constraints) } private func configureFooterViewLayout() { guard case let .presented(appearance) = viewControllerAppearance.footerViewState else { return } footerView.translatesAutoresizingMaskIntoConstraints = false let footerViewConstraints = SubviewConstraints( leadingConstraint: footerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), trailingConstraint: footerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), bottomConstraint: footerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), widthConstraint: footerView.widthAnchor.constraint(equalToConstant: .zero), heightConstraint: footerView.heightAnchor.constraint(equalToConstant: .zero)) self.footerViewConstraints = footerViewConstraints NSLayoutConstraint.deactivate(footerViewConstraints.sizeConstraints) UIView.configure(layout: appearance.layout, constraints: footerViewConstraints) } private func configureDragViewAppearance() { switch viewControllerAppearance.dragViewState { case .hidden: dragView.isHidden = true case let .presented(appearance): dragView.configure(appearance: appearance) } } private func configureHeaderViewAppearance() { switch viewControllerAppearance.headerViewState { case .hidden: headerView.isHidden = true case let .presented(appearance): headerView.configure(appearance: appearance) } } private func configureFooterViewAppearance() { switch viewControllerAppearance.footerViewState { case .hidden: footerView.isHidden = true case let .presented(appearance): footerView.configureAppearance(appearance: appearance) } } private func getSortedDetents() -> [ModalViewPresentationDetent] { presentationDetents.uniqued().sorted() } private func getHeight(of view: UIView) -> CGFloat { guard !view.isHidden else { return .zero } return getFittingSize(forView: view).height } private func getDragViewVerticalInsets() -> CGFloat { guard case let .presented(appearance) = viewControllerAppearance.dragViewState else { return .zero } return appearance.layout.insets.vertical } private func getHeaderViewVerticalInsets() -> CGFloat { guard case let .presented(appearance) = viewControllerAppearance.headerViewState else { return .zero } return appearance.layout.insets.vertical } private func getFittingSize(forView view: UIView) -> CGSize { let targetSize = CGSize(width: UIScreen.main.bounds.width, height: UIView.layoutFittingCompressedSize.height) return view.systemLayoutSizeFitting(targetSize) } private func getKeyboardHeight(_ notification: Notification) -> CGFloat? { guard let userInfo = notification.userInfo else { return nil } return (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height } }