diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift index 8a79d2fae9..e670a9263e 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift @@ -15,6 +15,7 @@ class NavigationControllerDemoController: DemoController { addTitle(text: "Large Title with Primary style") container.addArrangedSubview(createButton(title: "Show without accessory", action: #selector(showLargeTitle))) container.addArrangedSubview(createButton(title: "Show with collapsible search bar", action: #selector(showLargeTitleWithShyAccessory))) + container.addArrangedSubview(createButton(title: "Show with collapsible search bar and pill segmented control", action: #selector(showLargeTitleWithShyAccessoryAndSecondaryAccessory))) container.addArrangedSubview(createButton(title: "Show with fixed search bar", action: #selector(showLargeTitleWithFixedAccessory))) container.addArrangedSubview(createButton(title: "Show without an avatar", action: #selector(showLargeTitleWithoutAvatar))) container.addArrangedSubview(createButton(title: "Show with a custom leading button", action: #selector(showLargeTitleWithCustomLeadingButton))) @@ -56,6 +57,9 @@ class NavigationControllerDemoController: DemoController { addTitle(text: "Top Accessory View") container.addArrangedSubview(createButton(title: "Show with top search bar for large screen width", action: #selector(showWithTopSearchBar))) + addTitle(text: "Top Accessory View with shy wide accessory view") + container.addArrangedSubview(createButton(title: "Show with top search bar for large screen width with a shy pill segment control", action: #selector(showWithTopSearchBarWithShySecondaryAccessoryView))) + addTitle(text: "Change Style Periodically") container.addArrangedSubview(createButton(title: "Change the style every second", action: #selector(showSearchChangingStyleEverySecond))) } @@ -87,6 +91,10 @@ class NavigationControllerDemoController: DemoController { presentController(withTitleStyle: .largeLeading, accessoryView: createAccessoryView(), contractNavigationBarOnScroll: true) } + @objc func showLargeTitleWithShyAccessoryAndSecondaryAccessory() { + presentController(withTitleStyle: .largeLeading, accessoryView: createAccessoryView(), secondaryAccessoryView: createSecondaryAccessoryView(), contractNavigationBarOnScroll: true) + } + @objc func showLargeTitleWithFixedAccessory() { presentController(withTitleStyle: .largeLeading, accessoryView: createAccessoryView(), contractNavigationBarOnScroll: false) } @@ -189,6 +197,10 @@ class NavigationControllerDemoController: DemoController { presentController(withTitleStyle: .largeLeading, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), showsTopAccessory: true, contractNavigationBarOnScroll: false) } + @objc func showWithTopSearchBarWithShySecondaryAccessoryView() { + presentController(withTitleStyle: .largeLeading, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), secondaryAccessoryView: createSecondaryAccessoryView(), showsTopAccessory: true, contractNavigationBarOnScroll: true) + } + @objc func showSearchChangingStyleEverySecond() { presentController(withTitleStyle: .largeLeading, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), showsTopAccessory: true, contractNavigationBarOnScroll: false, updateStylePeriodically: true) } @@ -207,6 +219,7 @@ class NavigationControllerDemoController: DemoController { subtitle: String? = nil, style: NavigationBar.Style = .primary, accessoryView: UIView? = nil, + secondaryAccessoryView: UIView? = nil, showsTopAccessory: Bool = false, contractNavigationBarOnScroll: Bool = true, showShadow: Bool = true, @@ -219,6 +232,7 @@ class NavigationControllerDemoController: DemoController { content.navigationItem.navigationBarStyle = style content.navigationItem.navigationBarShadow = showShadow ? .automatic : .alwaysHidden content.navigationItem.accessoryView = accessoryView + content.navigationItem.secondaryAccessoryView = secondaryAccessoryView content.navigationItem.topAccessoryViewAttributes = NavigationBarTopSearchBarAttributes() content.navigationItem.contentScrollView = contractNavigationBarOnScroll ? content.tableView : nil content.showsTopAccessoryView = showsTopAccessory @@ -295,6 +309,16 @@ class NavigationControllerDemoController: DemoController { return searchBar } + private func createSecondaryAccessoryView() -> UIView { + let segmentControl = createSegmentedControl(compatibleWith: .system) + let stackView = UIStackView() + stackView.addArrangedSubview(segmentControl) + stackView.layoutMargins = UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.backgroundColor = view.fluentTheme.color(.background1) + return stackView + } + private func createSegmentedControl(compatibleWith style: NavigationBar.Style) -> UIView { let segmentItems: [SegmentItem] = [ SegmentItem(title: "First"), @@ -481,6 +505,7 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe } var showsTopAccessoryView: Bool = false + var secondaryAccessoryView: UIView? var personaData: PersonaData = { let personaData = PersonaData(name: "Kat Larsson", image: UIImage(named: "avatar_kat_larsson")) @@ -835,6 +860,10 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe extension RootViewController: SearchBarDelegate { func searchBarDidBeginEditing(_ searchBar: SearchBar) { searchBar.progressSpinner.state.isAnimating = false + if navigationItem.secondaryAccessoryView != nil && !showsTopAccessoryView { + secondaryAccessoryView = navigationItem.secondaryAccessoryView + navigationItem.secondaryAccessoryView = nil + } } func searchBar(_ searchBar: SearchBar, didUpdateSearchText newSearchText: String?) { @@ -842,6 +871,9 @@ extension RootViewController: SearchBarDelegate { func searchBarDidCancel(_ searchBar: SearchBar) { searchBar.progressSpinner.state.isAnimating = false + if secondaryAccessoryView != nil && !showsTopAccessoryView { + navigationItem.secondaryAccessoryView = secondaryAccessoryView + } } func searchBarDidRequestSearch(_ searchBar: SearchBar) { diff --git a/ios/FluentUI/Navigation/Shy Header/ShyHeaderController.swift b/ios/FluentUI/Navigation/Shy Header/ShyHeaderController.swift index b64b8b892a..36f75ddc27 100644 --- a/ios/FluentUI/Navigation/Shy Header/ShyHeaderController.swift +++ b/ios/FluentUI/Navigation/Shy Header/ShyHeaderController.swift @@ -45,6 +45,7 @@ class ShyHeaderController: UIViewController { } private var accessoryViewObservation: NSKeyValueObservation? + private var secondaryAccessoryViewObservation: NSKeyValueObservation? private var navigationBarCenterObservation: NSKeyValueObservation? private var navigationBarStyleObservation: NSKeyValueObservation? @@ -101,6 +102,10 @@ class ShyHeaderController: UIViewController { accessoryViewObservation = contentViewController.navigationItem.observe(\UINavigationItem.accessoryView) { [weak self] item, _ in self?.shyHeaderView.accessoryView = item.accessoryView } + + secondaryAccessoryViewObservation = contentViewController.navigationItem.observe(\UINavigationItem.secondaryAccessoryView) { [weak self] item, _ in + self?.shyHeaderView.secondaryAccessoryView = item.secondaryAccessoryView + } } required init?(coder aDecoder: NSCoder) { @@ -211,8 +216,10 @@ class ShyHeaderController: UIViewController { } private func setupShyHeaderView() { - shyHeaderView.accessoryView = contentViewController.navigationItem.accessoryView - shyHeaderView.navigationBarShadow = contentViewController.navigationItem.navigationBarShadow + let navigationItem = contentViewController.navigationItem + shyHeaderView.accessoryView = navigationItem.accessoryView + shyHeaderView.secondaryAccessoryView = navigationItem.secondaryAccessoryView + shyHeaderView.navigationBarShadow = navigationItem.navigationBarShadow shyHeaderView.paddingView = paddingView shyHeaderView.parentController = self shyHeaderView.maxHeightChanged = { [weak self] in diff --git a/ios/FluentUI/Navigation/Shy Header/ShyHeaderView.swift b/ios/FluentUI/Navigation/Shy Header/ShyHeaderView.swift index 5b953b5160..55e9fcebe4 100644 --- a/ios/FluentUI/Navigation/Shy Header/ShyHeaderView.swift +++ b/ios/FluentUI/Navigation/Shy Header/ShyHeaderView.swift @@ -72,6 +72,7 @@ class ShyHeaderView: UIView, TokenizedControlInternal { tokenSet.registerOnUpdate(for: self) { [weak self] in self?.updateColors() } + self.initSecondaryContentStackView() } override func willMove(toWindow newWindow: UIWindow?) { @@ -125,6 +126,13 @@ class ShyHeaderView: UIView, TokenizedControlInternal { willSet { accessoryView?.removeFromSuperview() contentStackView.removeFromSuperview() + // When there is no accessoryView, the top anchor of the secondaryContentStackView should be equal to + // the top anchor of the parent view. + if let secondaryContentStackViewTopAnchorConstraint { + NSLayoutConstraint.activate([ + secondaryContentStackViewTopAnchorConstraint + ]) + } } didSet { if let newContentView = accessoryView { @@ -135,13 +143,39 @@ class ShyHeaderView: UIView, TokenizedControlInternal { } } - var maxHeight: CGFloat { + var secondaryAccessoryView: UIView? { + willSet { + secondaryAccessoryView?.removeFromSuperview() + } + didSet { + if let newContentView = secondaryAccessoryView { + secondaryContentStackView.addArrangedSubview(newContentView) + } + maxHeightChanged?() + } + } + + var accessoryViewHeight: CGFloat { if accessoryView == nil { return maxHeightNoAccessory } else { return contentTopInset + Constants.accessoryHeight + contentBottomInset } } + + var secondaryAccessoryViewHeight: CGFloat { + guard let secondaryAccessoryView else { + return 0.0 + } + + let secondaryAccessoryViewSize = secondaryAccessoryView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + return secondaryAccessoryViewSize.height + } + + var maxHeight: CGFloat { + return accessoryViewHeight + secondaryAccessoryViewHeight + } + private var maxHeightNoAccessory: CGFloat { if traitCollection.verticalSizeClass == .compact { return traitCollection.horizontalSizeClass == .compact ? Constants.maxHeightNoAccessoryCompact : Constants.maxHeightNoAccessoryCompactForLargePhone @@ -186,6 +220,9 @@ class ShyHeaderView: UIView, TokenizedControlInternal { } private let contentStackView = UIStackView() + private var contentStackViewHeightConstraint: NSLayoutConstraint? + private let secondaryContentStackView = UIStackView() + private var secondaryContentStackViewTopAnchorConstraint: NSLayoutConstraint? private let shadow = Separator() private var needsShadow: Bool { @@ -222,12 +259,45 @@ class ShyHeaderView: UIView, TokenizedControlInternal { private func initContentStackView() { contentStackView.isLayoutMarginsRelativeArrangement = true + contentStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(contentStackView) - contentStackView.fitIntoSuperview(usingConstraints: true) + + // When there is a accessoryView, the top anchor of the secondaryContentStackView should be equal to + // the bottom anchor of contentStackView. + if let secondaryContentStackViewTopAnchorConstraint { + NSLayoutConstraint.deactivate([ + secondaryContentStackViewTopAnchorConstraint + ]) + } + + let heightConstraint = contentStackView.heightAnchor.constraint(equalToConstant: accessoryViewHeight) + contentStackViewHeightConstraint = heightConstraint + NSLayoutConstraint.activate([ + contentStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentStackView.topAnchor.constraint(equalTo: topAnchor), + contentStackView.bottomAnchor.constraint(equalTo: secondaryContentStackView.topAnchor), + heightConstraint + ]) updateContentInsets() contentStackView.addInteraction(UILargeContentViewerInteraction()) } + private func initSecondaryContentStackView() { + secondaryContentStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(secondaryContentStackView) + let topAnchorConstraint = secondaryContentStackView.topAnchor.constraint(equalTo: topAnchor) + secondaryContentStackViewTopAnchorConstraint = topAnchorConstraint + NSLayoutConstraint.activate([ + secondaryContentStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + secondaryContentStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + topAnchorConstraint, + secondaryContentStackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + secondaryContentStackView.addInteraction(UILargeContentViewerInteraction()) + } + private func initShadow() { let shadowView = shadow shadowView.translatesAutoresizingMaskIntoConstraints = false diff --git a/ios/FluentUI/Navigation/UINavigationItem+Navigation.swift b/ios/FluentUI/Navigation/UINavigationItem+Navigation.swift index f84fb3cde7..ba346097a1 100644 --- a/ios/FluentUI/Navigation/UINavigationItem+Navigation.swift +++ b/ios/FluentUI/Navigation/UINavigationItem+Navigation.swift @@ -8,6 +8,7 @@ import UIKit @objc public extension UINavigationItem { private struct AssociatedKeys { static var accessoryView: UInt8 = 0 + static var secondaryAccessoryView: UInt8 = 0 static var titleAccessory: UInt8 = 0 static var titleImage: UInt8 = 0 static var topAccessoryView: UInt8 = 0 @@ -31,6 +32,18 @@ import UIKit } } + /// An wide accessory view that can be shown as a subview of ShyHeaderView but doesn't have leading, trailing + /// and bottom insets. So it can appear as being part of the content view but still contract and expand as part of + /// the shy header. + var secondaryAccessoryView: UIView? { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.secondaryAccessoryView) as? UIView + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.secondaryAccessoryView, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + /// Defines an accessory shown after the title or subtitle in a navigation bar. When defined, this gives the indication that the title can be tapped to show additional information. var titleAccessory: NavigationBarTitleAccessory? { get {