Signal-iOS/SignalUI/ViewControllers/OWSViewController.swift
Evan Hahn f07735f706 Precommit script should fail if lint fails
This change should have no user impact.

I thought I did this in 1b00741b6d7dbfbe48e5b1c46f856902d0e6d02a...this
*actually* does it.
2022-10-28 15:51:02 -07:00

302 lines
11 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
@objc
open class OWSViewController: UIViewController {
/// If observing keyboard events is unecessary, override this to true to just drop them for a slight performance gain.
public var shouldIgnoreKeyboardChanges = false
// If true, the bottom view never "reclaims" layout space if the keyboard is dismissed.
// Defaults to false.
public var shouldBottomViewReserveSpaceForKeyboard = false
/// If true, sets the root view's background color to `Theme.background` on load.
public var shouldUseTheme: Bool = false
@discardableResult
public final func autoPinView(
toBottomOfViewControllerOrKeyboard view: UIView,
avoidNotch: Bool,
adjustmentWithKeyboardPresented adjustment: CGFloat = 0
) -> NSLayoutConstraint {
owsAssertDebug(self.bottomLayoutConstraint == nil)
self.observeNotificationsForBottomView()
self.bottomLayoutView = view
self.keyboardAdjustmentOffsetForAutoPinnedToBottomView = adjustment
if avoidNotch {
self.bottomLayoutConstraint = view.autoPin(toBottomLayoutGuideOf: self, withInset: self.lastBottomLayoutInset)
} else {
self.bottomLayoutConstraint = view.autoPinEdge(
.bottom,
to: .bottom,
of: self.view,
withOffset: self.lastBottomLayoutInset
)
}
return self.bottomLayoutConstraint!
}
public final func removeBottomLayout() {
bottomLayoutConstraint?.autoRemove()
bottomLayoutView = nil
bottomLayoutConstraint = nil
}
@objc
open dynamic func themeDidChange() {
AssertIsOnMainThread()
applyTheme()
}
@objc
open dynamic func applyTheme() {
AssertIsOnMainThread()
// Do nothing; this is a convenience hook for subclasses.
}
public init() {
super.init(nibName: nil, bundle: nil)
self.observeActivation()
}
deinit {
// Surface memory leaks by logging the deallocation of view controllers.
OWSLogger.verbose("Dealloc: \(type(of: self))")
}
@available(*, unavailable)
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
open override func viewDidLoad() {
super.viewDidLoad()
if shouldUseTheme {
view.backgroundColor = Theme.backgroundColor
}
NotificationCenter.default.addObserver(
self,
selector: #selector(themeDidChange),
name: .ThemeDidChange,
object: nil
)
}
open override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
shouldAnimateBottomLayout = true
#if DEBUG
ensureNavbarAccessibilityIds()
#endif
}
open override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
shouldAnimateBottomLayout = false
}
#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 observeActivation() {
NotificationCenter.default.addObserver(
self,
selector: #selector(owsViewControllerApplicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
@objc
private func owsViewControllerApplicationDidBecomeActive() {
setNeedsStatusBarAppearanceUpdate()
}
// MARK: - Keyboard Layout
private var lastBottomLayoutInset: CGFloat = 0
private var bottomLayoutView: UIView?
private var bottomLayoutConstraint: NSLayoutConstraint?
private var shouldAnimateBottomLayout = false
private var keyboardAdjustmentOffsetForAutoPinnedToBottomView: CGFloat = 0
private var hasObservedNotifications = false
private func observeNotificationsForBottomView() {
AssertIsOnMainThread()
guard !hasObservedNotifications else {
return
}
self.hasObservedNotifications = true
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotificationBase),
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotificationBase),
name: UIResponder.keyboardDidShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotificationBase),
name: UIResponder.keyboardWillHideNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotificationBase),
name: UIResponder.keyboardDidHideNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotificationBase),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotificationBase),
name: UIResponder.keyboardDidChangeFrameNotification,
object: nil
)
}
@objc
private func handleKeyboardNotificationBase(_ notification: NSNotification) {
AssertIsOnMainThread()
guard !shouldIgnoreKeyboardChanges else {
return
}
let userInfo = notification.userInfo
guard var keyboardEndFrame = (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else {
owsFailDebug("Missing keyboard end frame")
return
}
if keyboardEndFrame == .zero {
// If reduce motion+crossfade transitions is on, in iOS 14 UIKit vends out a keyboard end frame
// of CGRect zero. This breaks the math below.
//
// If our keyboard end frame is CGRectZero, build a fake rect that's translated off the bottom edge.
let deviceBounds = UIScreen.main.bounds
keyboardEndFrame = deviceBounds.offsetBy(dx: 0, dy: deviceBounds.height)
}
let keyboardEndFrameConverted = self.view.convert(keyboardEndFrame, from: nil)
// Adjust the position of the bottom view to account for the keyboard's
// intrusion into the view.
//
// On iPhones with no physical home button, when no keyboard is present,
// we include a buffer at the bottom of the screen so the bottom view
// clears the floating "home button". But because the keyboard includes it's own buffer,
// we subtract the size of bottom "safe area",
// else we'd have an unnecessary buffer between the popped keyboard and the input bar.
let newInset = max(
0,
self.view.height
+ self.keyboardAdjustmentOffsetForAutoPinnedToBottomView
- self.view.safeAreaInsets.bottom
- keyboardEndFrameConverted.origin.y
)
self.lastBottomLayoutInset = newInset
let rawCurve = userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int
let curve = UIView.AnimationCurve(rawValue: rawCurve ?? 0) ?? .easeInOut
let duration = userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0
// Should we ignore keyboard changes if they're coming from somewhere out-of-process?
// BOOL isOurKeyboard = [notification.userInfo[UIKeyboardIsLocalUserInfoKey] boolValue];
let updateLayout = {
if self.shouldBottomViewReserveSpaceForKeyboard, newInset == 0 {
// To avoid unnecessary animations / layout jitter,
// some views never reclaim layout space when the keyboard is dismissed.
//
// They _do_ need to relayout if the user switches keyboards.
return
}
self.updateBottomLayoutConstraint(
fromInset: -1 * (self.bottomLayoutConstraint?.constant ?? 0),
toInset: newInset
)
}
if self.shouldAnimateBottomLayout, duration > 0, !UIAccessibility.isReduceMotionEnabled {
UIView.beginAnimations("keyboardStateChange", context: nil)
UIView.setAnimationBeginsFromCurrentState(true)
UIView.setAnimationCurve(curve)
UIView.setAnimationDuration(duration)
updateLayout()
UIView.commitAnimations()
} else {
// UIKit by default (sometimes? never?) animates all changes in response to keyboard events.
// We want to suppress those animations if the view isn't visible,
// otherwise presentation animations don't work properly.
UIView.performWithoutAnimation {
updateLayout()
}
}
}
@objc
open dynamic func updateBottomLayoutConstraint(fromInset before: CGFloat, toInset after: CGFloat) {
self.bottomLayoutConstraint?.constant = -after
self.bottomLayoutView?.superview?.layoutIfNeeded()
}
// MARK: - Orientation
open override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return UIDevice.current.defaultSupportedOrientations
}
}