method to put provided views into a vertical stack view. • method to put provided buttons into a vertical stack view. All these would allow me to update reg flow screens to look the same without specifying layout metrics multiple times.
556 lines
21 KiB
Swift
556 lines
21 KiB
Swift
//
|
|
// 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<ViewControllerLifecycle>()
|
|
|
|
// 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
|
|
}
|
|
|
|
open override 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
|
|
)
|
|
}
|
|
|
|
open override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
self.lifecycle = .willAppear
|
|
|
|
observeKeyboardNotificationsIfNeeded()
|
|
}
|
|
|
|
open override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
self.lifecycle = .appeared
|
|
|
|
#if DEBUG
|
|
ensureNavbarAccessibilityIds()
|
|
#endif
|
|
}
|
|
|
|
open override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
self.lifecycle = .willDisappear
|
|
}
|
|
|
|
open override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
|
|
self.lifecycle = .notAppeared
|
|
}
|
|
|
|
open override 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
|
|
}
|
|
|
|
open override 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.
|
|
///
|
|
final public 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)
|
|
}
|
|
|
|
open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
|
|
if previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass ||
|
|
previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass
|
|
{
|
|
updateContentLayoutGuideConstraints()
|
|
}
|
|
}
|
|
|
|
// MARK: - Orientation
|
|
|
|
open override 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)
|
|
final public 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
|
|
}
|
|
}
|