This change should have no user impact. I thought I did this in 1b00741b6d7dbfbe48e5b1c46f856902d0e6d02a...this *actually* does it.
302 lines
11 KiB
Swift
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
|
|
}
|
|
}
|