// // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit public enum ViewControllerLifecycle: Equatable { /// `viewDidLoad` hasn't happened yet. case notLoaded /// Prior to `viewWillAppear` and after `viewDidDisappear`. case notAppeared /// After `viewWillAppear` and before `viewDidAppear`. case willAppear /// After `viewDidAppear` and before `viewWillDisappear`. case appeared /// After `viewWillDisappear` and before `viewDidDisappear`. case willDisappear var isLoaded: Bool { return self != .notLoaded } var isVisible: Bool { switch self { case .willAppear, .appeared, .willDisappear: return true default: return false } } } open class OWSViewController: UIViewController { /// Current state of the view lifecycle. /// Note changes are triggered by the lifecycle methods `viewDidLoad` `viewWillAppear` `viewDidAppear` /// `viewWillDisappear` `viewDidDisappear`; those can be overridden to get state change hooks as per normal. public private(set) final var lifecycle = ViewControllerLifecycle.notLoaded { didSet { achievedLifecycleStates.insert(lifecycle) } } /// All lifecycle states achieved so far in the lifetime of this view controller. public private(set) final var achievedLifecycleStates = Set() // MARK: - Themeing and content size categories /// An overridable method for subclasses to hook into theme changes, to /// adjust their contents. @objc open func themeDidChange() { AssertIsOnMainThread() } /// An overridable method for subclasses to hook into content size category /// changes, to ensure their content adapts. @objc open func contentSizeCategoryDidChange() { AssertIsOnMainThread() } // MARK: - Init public init() { super.init(nibName: nil, bundle: nil) self.observeAppState() } deinit { // Surface memory leaks by logging the deallocation of view controllers. Logger.verbose("Dealloc: \(type(of: self))") } @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Lifecycle /// Subclasses can override to respond to application state changes. /// NOTE: overrides _must_ call the superclass version of this method, similarly to other view lifecycle methods. @objc open func appWillEnterForeground() { // Do nothing; just a hook for subclasses } /// Subclasses can override to respond to application state changes. /// NOTE: overrides _must_ call the superclass version of this method, similarly to other view lifecycle methods. @objc open func appDidBecomeActive() { setNeedsStatusBarAppearanceUpdate() } /// Subclasses can override to respond to application state changes. /// NOTE: overrides _must_ call the superclass version of this method, similarly to other view lifecycle methods. @objc open func appWillResignActive() { // Do nothing; just a hook for subclasses } /// Subclasses can override to respond to application state changes. /// NOTE: overrides _must_ call the superclass version of this method, similarly to other view lifecycle methods. @objc open func appDidEnterBackground() { // Do nothing; just a hook for subclasses } override open func viewDidLoad() { super.viewDidLoad() self.lifecycle = .notAppeared installContentLayouGuide() if #unavailable(iOS 16) { let layoutGuide = UILayoutGuide() layoutGuide.identifier = "iOS15KeyboardLayoutGuide" view.addLayoutGuide(layoutGuide) let heightConstraint = layoutGuide.heightAnchor.constraint(equalToConstant: view.safeAreaInsets.bottom) NSLayoutConstraint.activate([ layoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), layoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), layoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), heightConstraint, ]) iOS15KeyboardLayoutGuide = layoutGuide iOS15KeyboardLayoutGuideHeightConstraint = heightConstraint } NotificationCenter.default.addObserver( self, selector: #selector(themeDidChange), name: .themeDidChange, object: nil, ) } override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.lifecycle = .willAppear observeKeyboardNotificationsIfNeeded() } override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.lifecycle = .appeared #if DEBUG ensureNavbarAccessibilityIds() #endif } override open func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.lifecycle = .willDisappear } override open func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.lifecycle = .notAppeared } override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) // Whatever keyboard frame we knew about is now invalidated. // They keyboard will update us if its on screen, setting this again. lastKnownKeyboardFrame = nil } override open func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() updateiOS15KeyboardLayoutGuide() } #if DEBUG func ensureNavbarAccessibilityIds() { guard let navigationBar = navigationController?.navigationBar else { return } // There isn't a great way to assign accessibilityIdentifiers to default // navbar buttons, e.g. the back button. As a (DEBUG-only) hack, we // assign accessibilityIds to any navbar controls which don't already have // one. This should offer a reliable way for automated scripts to find // these controls. // // UINavigationBar often discards and rebuilds new contents, e.g. between // presentations of the view, so we need to do this every time the view // appears. We don't do any checking for accessibilityIdentifier collisions // so we're counting on the fact that navbar contents are short-lived. var accessibilityIdCounter = 0 navigationBar.traverseHierarchyDownward { view in if view is UIControl, view.accessibilityIdentifier == nil { // The view should probably be an instance of _UIButtonBarButton or _UIModernBarButton. view.accessibilityIdentifier = String(format: "navbar-%ld", accessibilityIdCounter) accessibilityIdCounter += 1 } } } #endif // MARK: - Activation private func observeAppState() { NotificationCenter.default.addObserver( self, selector: #selector(appWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil, ) } @objc private func owsViewControllerApplicationDidBecomeActive() { setNeedsStatusBarAppearanceUpdate() } // MARK: - Content Layout Guide /// Defines an area for static content to be laid in. /// /// `contentLayoutGuide` is meant to provide subclasses with a unified area for static content. /// This layout guide is designed to be used across all devices and interface orientations. /// /// /// These are the margins `contentLayoutGuide` defines relative to root view's edges: /// * iPhone portrait (vertical regular, horizontal compact) /// * Top /// * Notch/Dymamic island iPhones: same as safe area. /// * Home button iPhones: same as status bar area (20 pt). /// * Leading/trailing /// * Plus/Max/Air iPhones: 20 pt. /// * Other iPhones: 16 pt. /// * Bottom /// * Notch/Dymamic island iPhones: same as safe area. /// * Home button iPhones: manual 20 pt to match top margin. /// /// * iPhone Landscape (vertical compact, horizontal regular on Plus/Max iPhones) /// * Top /// * Same as safe area, which is mostly 20 pt but can be zero /// on smaller phones running older iOS versions. /// * Leading/trailing /// * Notch/Dymamic island iPhones: safe area + 16 pts, more if content width is capped at 640 pts. /// * Home button iPhones: 20 pt. /// * Bottom /// * All iPhones: 20 pt. /// /// * iPad /// * Usable margins (20 or 10 pt) on all sides. /// public final var contentLayoutGuide = UILayoutGuide() private var currentContentLayoutGuideConstraints: [NSLayoutConstraint] = [] private func installContentLayouGuide() { contentLayoutGuide.identifier = "Static Content Layout Guide" view.addLayoutGuide(contentLayoutGuide) // Permanent constraints. NSLayoutConstraint.activate([ contentLayoutGuide.centerXAnchor.constraint(equalTo: view.layoutMarginsGuide.centerXAnchor), ]) // Flexible constraints. updateContentLayoutGuideConstraints() } private func contentLayoutConstraintsForCurrentTraitCollection() -> [NSLayoutConstraint] { var constraints = [NSLayoutConstraint]() let isVerticalCompact = traitCollection.verticalSizeClass == .compact let isHorizontalCompact = traitCollection.horizontalSizeClass == .compact let isiPad = traitCollection.userInterfaceIdiom == .pad Logger.debug("Vertical compact: [\(isVerticalCompact ? "Y" : "N")]") Logger.debug("Horizontal compact: [\(isHorizontalCompact ? "Y" : "N")]") Logger.debug("Layout margins: [\(view.layoutMarginsGuide.layoutFrame)]") // Vertical if isVerticalCompact { // Whole available height. constraints += [ contentLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), contentLayoutGuide.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor), ] } else { var bottomMargin: CGFloat = 0 // iPhones with home button have zero bottom layout margin for some reason. No bueno! if !isiPad, !UIDevice.current.hasIPhoneXNotch { bottomMargin = 20 } constraints += [ contentLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), contentLayoutGuide.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: -bottomMargin), ] } // Horizontal if isiPad, !isHorizontalCompact { // No wider than 628 pts, centered. // 628 is the minimum width of `layoutMarginsGuide.frame` when horizonal size class is regular. constraints.append({ let constraint = contentLayoutGuide.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor) constraint.priority = .init(UILayoutPriority.required.rawValue - 10) return constraint }()) constraints += [ contentLayoutGuide.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), contentLayoutGuide.widthAnchor.constraint(lessThanOrEqualToConstant: 628), ] } else { // Whole available width. constraints += [ contentLayoutGuide.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), ] } return constraints } private func updateContentLayoutGuideConstraints() { NSLayoutConstraint.deactivate(currentContentLayoutGuideConstraints) currentContentLayoutGuideConstraints = contentLayoutConstraintsForCurrentTraitCollection() NSLayoutConstraint.activate(currentContentLayoutGuideConstraints) } override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass || previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass { updateContentLayoutGuideConstraints() } } // MARK: - Orientation override open var supportedInterfaceOrientations: UIInterfaceOrientationMask { return UIDevice.current.defaultSupportedOrientations } // MARK: - Keyboard Layout Guide // On iOS 15 provides access to last known keyboard frame. // On newer iOS versions this is a proxy for `view.keyboardLayoutGuide`. @available(iOS, deprecated: 16.0) public final var keyboardLayoutGuide: UILayoutGuide { return iOS15KeyboardLayoutGuide ?? view.keyboardLayoutGuide } @available(iOS, deprecated: 16.0) private var iOS15KeyboardLayoutGuide: UILayoutGuide? @available(iOS, deprecated: 16.0) private var iOS15KeyboardLayoutGuideHeightConstraint: NSLayoutConstraint? @available(iOS, deprecated: 16.0) private var isObservingKeyboardNotifications = false @available(iOS, deprecated: 16.0) private var lastKnownKeyboardFrame: CGRect? @available(iOS, deprecated: 16.0) private static var keyboardNotificationNames: [Notification.Name] = [ UIResponder.keyboardWillShowNotification, UIResponder.keyboardDidShowNotification, UIResponder.keyboardWillHideNotification, UIResponder.keyboardDidHideNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardDidChangeFrameNotification, ] @available(iOS, deprecated: 16.0) private func observeKeyboardNotificationsIfNeeded() { guard #unavailable(iOS 16.0) else { return } if isObservingKeyboardNotifications { return } isObservingKeyboardNotifications = true Self.keyboardNotificationNames.forEach { NotificationCenter.default.addObserver( self, selector: #selector(handleKeyboardNotificationBase(_:)), name: $0, object: nil, ) } } @available(iOS, deprecated: 16.0) private func stopObservingKeyboardNotifications() { Self.keyboardNotificationNames.forEach { NotificationCenter.default.removeObserver(self, name: $0, object: nil) } isObservingKeyboardNotifications = false } @objc @available(iOS, deprecated: 16.0) private func handleKeyboardNotificationBase(_ notification: NSNotification) { let userInfo = notification.userInfo guard let keyboardEndFrame = (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { owsFailDebug("Missing keyboard end frame") return } let keyboardEndFrameConverted = view.convert(keyboardEndFrame, from: nil) guard keyboardEndFrameConverted != lastKnownKeyboardFrame else { // No change. return } lastKnownKeyboardFrame = keyboardEndFrameConverted updateiOS15KeyboardLayoutGuide() } @available(iOS, deprecated: 16.0) private func updateiOS15KeyboardLayoutGuide() { guard let iOS15KeyboardLayoutGuideHeightConstraint else { return } var keyboardHeight = view.safeAreaInsets.bottom if let lastKnownKeyboardFrame { keyboardHeight = max(keyboardHeight, view.bounds.maxY - lastKnownKeyboardFrame.minY) } guard iOS15KeyboardLayoutGuideHeightConstraint.constant != keyboardHeight else { return } iOS15KeyboardLayoutGuideHeightConstraint.constant = keyboardHeight } } private class PassthroughTouchSpacerView: SpacerView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let view = super.hitTest(point, with: event) if view == self { return nil } return view } } public extension OWSViewController { /// Add provided views to view controller's view hierarchy in a vertical stack. /// /// Use this method for adding vertically aligned static content to the view controller's view. /// /// - Parameters: /// - arrangedSubviews: Views to add to the view hierarchy. /// - isScrollable: If set to `true`, stack view will be embedded in a vertical scroll view. Use this if there's a chance that content won't fit screen height. /// - shouldAvoidKeyboard: If set to `true`, bottom edge of the stack view will be pinned to top of the keyboard. /// /// - Returns: /// A vertical stack view that has been configured using default parameters and added to view controller's view along with necessary auto layout constraints. @discardableResult func addStaticContentStackView( arrangedSubviews: [UIView], isScrollable: Bool = false, shouldAvoidKeyboard: Bool = false, ) -> UIStackView { let stackView = UIStackView(arrangedSubviews: arrangedSubviews) stackView.axis = .vertical stackView.spacing = 12 stackView.distribution = .fill stackView.translatesAutoresizingMaskIntoConstraints = false if isScrollable { let scrollView = UIScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(scrollView) scrollView.addSubview(stackView) NSLayoutConstraint.activate([ // Scroll view's top is constrained to `contentLayoutGuide`. scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), // Scroll view's bottom is constrained either to `contentLayouGuide` or to `keyboardLayoutGuide`. { if shouldAvoidKeyboard { scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor) } else { scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor) } }(), // Scroll view is horizontally constrained to root view's safe area. // This is done so that scroll view's indicator isn't too close to the content. scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), // Stack view is vertically constrained to scroll view's `contentLayoutGuide`. stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), // Stack view is stretched vertically to fill scroll view's height. stackView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor), // Stack view is horizontally constrained to `contentLayoutGuide`. stackView.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor), ]) } else { view.addSubview(stackView) NSLayoutConstraint.activate([ // Stack view is constrained to `contentLayoutGuide` in all but one directions. stackView.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), stackView.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor), // Stack view's bottom is constrained either to `contentLayouGuide` or to `keyboardLayoutGuide`. { if shouldAvoidKeyboard { stackView.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor) } else { stackView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor) } }(), ]) } return stackView } }