// // 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 import PanModal 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 private(set) lazy var dragViewBottomToHeaderViewTopConstraint: NSLayoutConstraint = { dragView.bottomAnchor.constraint(equalTo: headerView.topAnchor) }() public private(set) lazy var dragViewBottomToContentViewTopConstraint: NSLayoutConstraint = { dragView.bottomAnchor.constraint(equalTo: contentView.topAnchor) }() public private(set) lazy var dragViewConstraints: SubviewConstraints = { let trailingConstraint = dragView.trailingAnchor.constraint(equalTo: view.trailingAnchor) let edgeConstraints = EdgeConstraints(leadingConstraint: dragView.leadingAnchor.constraint(equalTo: view.leadingAnchor), trailingConstraint: trailingConstraint, topConstraint: dragView.topAnchor.constraint(equalTo: view.topAnchor), bottomConstraint: dragViewBottomToHeaderViewTopConstraint) let centerXConstraint = dragView.centerXAnchor.constraint(equalTo: view.centerXAnchor) let centerYConstraint = dragView.centerYAnchor.constraint(equalTo: view.centerYAnchor) let centerConstraints = CenterConstraints(centerXConstraint: centerXConstraint, centerYConstraint: centerYConstraint) let sizeConstraints = SizeConstraints(widthConstraint: dragView.widthAnchor.constraint(equalToConstant: .zero), heightConstraint: dragView.heightAnchor.constraint(equalToConstant: .zero)) return SubviewConstraints(edgeConstraints: edgeConstraints, centerConstraints: centerConstraints, sizeConstraints: sizeConstraints) }() public private(set) lazy var headerViewToSuperviewTopConstraint: NSLayoutConstraint = { headerView.topAnchor.constraint(equalTo: view.topAnchor) }() public private(set) lazy var headerBottomToContentTopConstraint: NSLayoutConstraint = { headerView.bottomAnchor.constraint(equalTo: contentView.topAnchor) }() public private(set) lazy var headerViewConstraints: SubviewConstraints = { let leadingConstraint = headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor) let trailingConstraint = headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) let edgeConstraints = EdgeConstraints(leadingConstraint: leadingConstraint, trailingConstraint: trailingConstraint, topConstraint: dragViewBottomToHeaderViewTopConstraint, bottomConstraint: headerBottomToContentTopConstraint) let centerXConstraint = headerView.centerXAnchor.constraint(equalTo: view.centerXAnchor) let centerYConstraint = headerView.centerYAnchor.constraint(equalTo: view.centerYAnchor) let centerConstraints = CenterConstraints(centerXConstraint: centerXConstraint, centerYConstraint: centerYConstraint) let sizeConstraints = SizeConstraints(widthConstraint: headerView.widthAnchor.constraint(equalToConstant: .zero), heightConstraint: headerView.heightAnchor.constraint(equalToConstant: .zero)) return SubviewConstraints(edgeConstraints: edgeConstraints, centerConstraints: centerConstraints, sizeConstraints: sizeConstraints) }() public private(set) lazy var contentViewTopToSuperviewConstraint: NSLayoutConstraint = { contentView.topAnchor.constraint(equalTo: view.topAnchor) }() public private(set) lazy var contentViewBottomToSuperviewConstraint: NSLayoutConstraint = { contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor) }() public private(set) lazy var contentViewConstraints: SubviewConstraints = { let leadingConstraint = contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor) let trailingConstraint = contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor) let edgeConstraints = EdgeConstraints(leadingConstraint: leadingConstraint, trailingConstraint: trailingConstraint, topConstraint: headerBottomToContentTopConstraint, bottomConstraint: contentViewBottomToSuperviewConstraint) let centerXConstraint = contentView.centerXAnchor.constraint(equalTo: view.centerXAnchor) let centerYConstraint = contentView.centerYAnchor.constraint(equalTo: view.centerYAnchor) let centerConstraints = CenterConstraints(centerXConstraint: centerXConstraint, centerYConstraint: centerYConstraint) let sizeConstraints = SizeConstraints(widthConstraint: contentView.widthAnchor.constraint(equalToConstant: .zero), heightConstraint: contentView.heightAnchor.constraint(equalToConstant: .zero)) return SubviewConstraints(edgeConstraints: edgeConstraints, centerConstraints: centerConstraints, sizeConstraints: sizeConstraints) }() public private(set) lazy var footerViewConstraints: SubviewConstraints = { let leadingConstraint = footerView.leadingAnchor.constraint(equalTo: view.leadingAnchor) let trailingConstraint = footerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) let edgeConstraints = EdgeConstraints(leadingConstraint: leadingConstraint, trailingConstraint: trailingConstraint, topConstraint: footerView.topAnchor.constraint(equalTo: contentView.bottomAnchor), bottomConstraint: footerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)) let centerXConstraint = footerView.centerXAnchor.constraint(equalTo: view.centerXAnchor) let centerYConstraint = footerView.centerYAnchor.constraint(equalTo: view.centerYAnchor) let centerConstraints = CenterConstraints(centerXConstraint: centerXConstraint, centerYConstraint: centerYConstraint) let sizeConstraints = SizeConstraints(widthConstraint: footerView.widthAnchor.constraint(equalToConstant: .zero), heightConstraint: footerView.heightAnchor.constraint(equalToConstant: .zero)) return SubviewConstraints(edgeConstraints: edgeConstraints, centerConstraints: centerConstraints, sizeConstraints: sizeConstraints) }() // MARK: - Modal View Controller Configuration public var viewControllerAppearance: BaseAppearance = .init(background: UIViewColorBackground(color: .white)) open var panScrollable: UIScrollView? { contentView as? UIScrollView } public var panScrollableInsets: UIEdgeInsets = .zero { didSet { panScrollable?.contentInset = panScrollableInsets } } public var dimmedView = DimmedView() public var showDragIndicator = true 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 } private var keyboardDidShownObserver: NSObjectProtocol? private var keyboardDidHiddenObserver: NSObjectProtocol? // MARK: - Life Cycle deinit { let notificationCenter = NotificationCenter.default if let keyboardDidShownObserver { notificationCenter.removeObserver(keyboardDidShownObserver) } if let keyboardDidHiddenObserver { notificationCenter.removeObserver(keyboardDidHiddenObserver) } } // MARK: - BaseInitializableViewController open override func addViews() { super.addViews() view.addSubviews(dragView, headerView, contentView, footerView) } open override func configureLayout() { super.configureLayout() for view in [dragView, headerView, contentView, footerView] { view.translatesAutoresizingMaskIntoConstraints = false } 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 keyboardHeight = getKeyboardHeight(notification) else { return } if case let .presented(footerViewAppearance) = viewControllerAppearance.footerViewState { let bottomInset = footerViewAppearance.layout.insets.add(\.bottom, to: \.bottom, of: .vertical(bottom: keyboardHeight)) footerViewConstraints.edgeConstraints.bottomConstraint.constant = bottomInset } else if let panScrollable { var insets = panScrollableInsets if isKeyboardHidden { panScrollable.contentInset = insets } else { insets.bottom += keyboardHeight panScrollable.contentInset = insets } } } // MARK: - Private Methods private func configureDragViewLayout() { guard case let .presented(dragViewAppearance) = viewControllerAppearance.dragViewState else { return } let bottomConstraint: NSLayoutConstraint let bottomConstant: CGFloat if case let .presented(headerViewAppearance) = viewControllerAppearance.headerViewState { dragViewBottomToContentViewTopConstraint.isActive = false bottomConstraint = dragViewBottomToHeaderViewTopConstraint bottomConstant = dragViewAppearance.layout.insets.add(\.bottom, to: \.top, of: headerViewAppearance.layout.insets) } else { dragViewBottomToHeaderViewTopConstraint.isActive = false bottomConstraint = dragViewBottomToContentViewTopConstraint bottomConstant = dragViewAppearance.layout.insets.bottom } dragViewConstraints.edgeConstraints.bottomConstraint = bottomConstraint dragViewConstraints.update(from: dragViewAppearance.layout) bottomConstraint.setActiveConstantOrDeactivate(constant: bottomConstant) } private func configureHeaderViewLayout() { guard case let .presented(headerViewAppearance) = viewControllerAppearance.headerViewState else { return } let topConstraint: NSLayoutConstraint let topConstant: CGFloat if case let .presented(dragViewAppearance) = viewControllerAppearance.dragViewState { dragViewBottomToContentViewTopConstraint.isActive = false topConstraint = dragViewBottomToHeaderViewTopConstraint topConstant = dragViewAppearance.layout.insets.add(\.bottom, to: \.top, of: headerViewAppearance.layout.insets) } else { dragViewBottomToHeaderViewTopConstraint.isActive = false topConstraint = headerViewToSuperviewTopConstraint let topInset = headerViewAppearance.layout.insets.top topConstant = topInset.isFinite ? topInset : .zero } headerViewConstraints.edgeConstraints.topConstraint = topConstraint headerViewConstraints.update(from: headerViewAppearance.layout) topConstraint.setActiveConstantOrDeactivate(constant: topConstant) } private func configureContentViewLayout() { let topConstraint: NSLayoutConstraint let topConstant: CGFloat if case let .presented(headerViewAppearance) = viewControllerAppearance.headerViewState { dragViewBottomToContentViewTopConstraint.isActive = false contentViewTopToSuperviewConstraint.isActive = false topConstraint = headerBottomToContentTopConstraint topConstant = headerViewAppearance.layout.insets.bottom } else if case let .presented(dragViewAppearance) = viewControllerAppearance.dragViewState { contentViewTopToSuperviewConstraint.isActive = false headerBottomToContentTopConstraint.isActive = false topConstraint = dragViewBottomToContentViewTopConstraint topConstant = dragViewAppearance.layout.insets.bottom } else { headerBottomToContentTopConstraint.isActive = false dragViewBottomToContentViewTopConstraint.isActive = false topConstraint = contentViewTopToSuperviewConstraint topConstant = .zero } contentViewConstraints.edgeConstraints.topConstraint = topConstraint let layout = UIView.DefaultWrappedLayout(insets: .horizontal(.zero) .vertical(top: topConstant) .replacingNan(with: .zero)) contentViewConstraints.update(from: layout) } private func configureFooterViewLayout() { guard case let .presented(footerViewAppearance) = viewControllerAppearance.footerViewState else { return } contentViewConstraints.edgeConstraints.bottomConstraint.isActive = false contentViewConstraints.edgeConstraints.bottomConstraint = footerViewConstraints.edgeConstraints.topConstraint footerViewConstraints.update(from: footerViewAppearance.layout) } 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.configureBaseWrappedViewHolder(appearance: appearance) } } private func getSortedDetents() -> [ModalViewPresentationDetent] { viewControllerAppearance.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(onNan: .zero) } private func getHeaderViewVerticalInsets() -> CGFloat { guard case let .presented(appearance) = viewControllerAppearance.headerViewState else { return .zero } return appearance.layout.insets.vertical(onNan: .zero) } 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 } }