diff --git a/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj b/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj index a38302fdea..87d5b2f6f0 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj +++ b/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ 532FE3DC26EA6D8D007539C0 /* ActivityIndicatorDemoController_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 532FE3DA26EA6D8D007539C0 /* ActivityIndicatorDemoController_SwiftUI.swift */; }; 5336B17D27F7817300B01E0D /* HUDDemoController_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5336B17B27F7817300B01E0D /* HUDDemoController_SwiftUI.swift */; }; 5373D55F2694C3070032A3B4 /* AvatarDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5373D55E2694C3070032A3B4 /* AvatarDemoController.swift */; }; + 66963D0C29CB792E006F5FA9 /* TwoLineTitleViewDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66963D0B29CB792E006F5FA9 /* TwoLineTitleViewDemoController.swift */; }; 6F453CA528AC536300ED91A4 /* ShadowTokensDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F453CA428AC536300ED91A4 /* ShadowTokensDemoController.swift */; }; 6FC8AD3B28DBAF280010C0F8 /* ReadmeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC8AD3A28DBAF280010C0F8 /* ReadmeViewController.swift */; }; 6FEED93B28A6E5520099D178 /* AliasColorTokensDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FEED93A28A6E5520099D178 /* AliasColorTokensDemoController.swift */; }; @@ -206,6 +207,7 @@ 5373D55E2694C3070032A3B4 /* AvatarDemoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarDemoController.swift; sourceTree = ""; }; 5373F95426F28D3A007F1410 /* IndeterminateProgressBarDemoController_SwiftUI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressBarDemoController_SwiftUI.swift; sourceTree = ""; }; 5373F95626F28D9B007F1410 /* IndeterminateProgressBarDemoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressBarDemoController.swift; sourceTree = ""; }; + 66963D0B29CB792E006F5FA9 /* TwoLineTitleViewDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwoLineTitleViewDemoController.swift; sourceTree = ""; }; 6F453CA428AC536300ED91A4 /* ShadowTokensDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowTokensDemoController.swift; sourceTree = ""; }; 6FC8AD3A28DBAF280010C0F8 /* ReadmeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadmeViewController.swift; sourceTree = ""; }; 6FEED93A28A6E5520099D178 /* AliasColorTokensDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AliasColorTokensDemoController.swift; sourceTree = ""; }; @@ -544,6 +546,7 @@ EC98E2B72992FE6900B9DF91 /* TextFieldObjCDemoController.h */, EC98E2B52992FE5000B9DF91 /* TextFieldObjCDemoController.m */, FD7DF06121FB941400857267 /* TooltipDemoController.swift */, + 66963D0B29CB792E006F5FA9 /* TwoLineTitleViewDemoController.swift */, 92D5FDFC28AC57650087894B /* TypographyTokensDemoController.swift */, 6FEED93A28A6E5520099D178 /* AliasColorTokensDemoController.swift */, 6F453CA428AC536300ED91A4 /* ShadowTokensDemoController.swift */, @@ -797,6 +800,7 @@ 5336B17D27F7817300B01E0D /* HUDDemoController_SwiftUI.swift in Sources */, 92561E732718AD090072ED00 /* DemoTableViewController.swift in Sources */, 43488C4A270FB7E800124C71 /* NotificationViewDemoController_SwiftUI.swift in Sources */, + 66963D0C29CB792E006F5FA9 /* TwoLineTitleViewDemoController.swift in Sources */, 497DC2DE24185896008D86F8 /* PillButtonBarDemoController.swift in Sources */, B4EF53C5215C45C400573E8F /* PersonaListViewDemoController.swift in Sources */, A5DCA75E211E3A92005F4CB7 /* DrawerDemoController.swift in Sources */, diff --git a/ios/FluentUI.Demo/FluentUI.Demo/ColoredPillBackgroundView.swift b/ios/FluentUI.Demo/FluentUI.Demo/ColoredPillBackgroundView.swift index 54a7c55caa..919ec9d1ee 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/ColoredPillBackgroundView.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/ColoredPillBackgroundView.swift @@ -50,9 +50,9 @@ class ColoredPillBackgroundView: UIView { func updateBackgroundColor() { switch style { case .neutralNavBar: - backgroundColor = NavigationBar.Style.system.backgroundColor(fluentTheme: fluentTheme) + backgroundColor = NavigationBar.backgroundColor(for: .system, theme: fluentTheme) case .brandNavBar: - backgroundColor = NavigationBar.Style.primary.backgroundColor(fluentTheme: fluentTheme) + backgroundColor = NavigationBar.backgroundColor(for: .primary, theme: fluentTheme) case .canvas: backgroundColor = fluentTheme.color(.backgroundCanvas) } diff --git a/ios/FluentUI.Demo/FluentUI.Demo/DemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/DemoController.swift index 958a859734..c143afb88e 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/DemoController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/DemoController.swift @@ -163,7 +163,7 @@ class DemoController: UIViewController { style: .plain, target: self, action: #selector(showAppearancePopover(_:))) - let readmeButton = UIBarButtonItem(image: UIImage(systemName: "i.circle.fill"), + let readmeButton = UIBarButtonItem(image: UIImage(systemName: "info.circle.fill"), style: .plain, target: self, action: #selector(showReadmePopover)) diff --git a/ios/FluentUI.Demo/FluentUI.Demo/DemoTableViewController.swift b/ios/FluentUI.Demo/FluentUI.Demo/DemoTableViewController.swift index ea9ba4942f..02551fd6f7 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/DemoTableViewController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/DemoTableViewController.swift @@ -54,7 +54,7 @@ class DemoTableViewController: UITableViewController { style: .plain, target: self, action: #selector(showAppearancePopover)) - let readmeButton = UIBarButtonItem(image: UIImage(systemName: "i.circle.fill"), + let readmeButton = UIBarButtonItem(image: UIImage(systemName: "info.circle.fill"), style: .plain, target: self, action: #selector(showReadmePopover)) diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift index 76cc22a90d..d7cb541f97 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift @@ -32,6 +32,7 @@ struct Demos { DemoDescriptor("IndeterminateProgressBar", IndeterminateProgressBarDemoController.self), DemoDescriptor("Label", LabelDemoController.self), DemoDescriptor("MultilineCommandBar", MultilineCommandBarDemoController.self), + DemoDescriptor("NavigationController", NavigationControllerDemoController.self), DemoDescriptor("NotificationView", NotificationViewDemoController.self), DemoDescriptor("Other cells", OtherCellsDemoController.self), DemoDescriptor("PersonaButtonCarousel", PersonaButtonCarouselDemoController.self), @@ -46,7 +47,8 @@ struct Demos { DemoDescriptor("TableViewCell", TableViewCellDemoController.self), DemoDescriptor("TableViewHeaderFooterView", TableViewHeaderFooterViewDemoController.self), DemoDescriptor("Text Field", TextFieldDemoController.self), - DemoDescriptor("Tooltip", TooltipDemoController.self) + DemoDescriptor("Tooltip", TooltipDemoController.self), + DemoDescriptor("TwoLineTitleView", TwoLineTitleViewDemoController.self) ] static let fluent2DesignTokens: [DemoDescriptor] = [ @@ -61,7 +63,6 @@ struct Demos { DemoDescriptor("BottomCommandingController", BottomCommandingDemoController.self), DemoDescriptor("Card", CardViewDemoController.self), DemoDescriptor("DateTimePicker", DateTimePickerDemoController.self), - DemoDescriptor("NavigationController", NavigationControllerDemoController.self), DemoDescriptor("PeoplePicker", PeoplePickerDemoController.self), DemoDescriptor("PersonaListView", PersonaListViewDemoController.self), DemoDescriptor("TableViewCellFileAccessoryView", TableViewCellFileAccessoryViewDemoController.self), diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/LabelDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/LabelDemoController.swift index a0c07bdbf2..1f6a7a610f 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos/LabelDemoController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/LabelDemoController.swift @@ -30,6 +30,24 @@ class LabelDemoController: DemoController { textColorLabels.append(addLabel(text: colorStyle.description, style: .body1, colorStyle: colorStyle)) } + addLabel(text: "Text Color Custom Styles", style: .body1Strong, colorStyle: .regular).textAlignment = .center + + let dangerSuccessLabel = Label(textStyle: .body1Strong, colorForTheme: { + theme in + UIColor(light: theme.color(.dangerForeground1), dark: theme.color(.successBackground2)) + }) + dangerSuccessLabel.text = "Danger/Success" + container.addArrangedSubview(dangerSuccessLabel) + textColorLabels.append(dangerSuccessLabel) + + let blueYellowLabel = Label(textStyle: .body1Strong, colorForTheme: { + _ in + UIColor(light: GlobalTokens.sharedColor(.blue, .primary), dark: GlobalTokens.sharedColor(.yellow, .primary)) + }) + blueYellowLabel.text = "Blue/Yellow" + container.addArrangedSubview(blueYellowLabel) + textColorLabels.append(blueYellowLabel) + container.addArrangedSubview(UIView()) // spacer NotificationCenter.default.addObserver(self, selector: #selector(handleContentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil) diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift index 07799d0bef..d6320f6638 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift @@ -17,6 +17,7 @@ class NavigationControllerDemoController: DemoController { container.addArrangedSubview(createButton(title: "Show with collapsible search bar", action: #selector(showLargeTitleWithShyAccessory))) 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))) container.addArrangedSubview(createButton(title: "Show with pill segmented control", action: #selector(showLargeTitleWithPillSegment))) addTitle(text: "Large Title with System style") @@ -26,9 +27,19 @@ class NavigationControllerDemoController: DemoController { container.addArrangedSubview(createButton(title: "Show with fixed search bar", action: #selector(showLargeTitleWithSystemStyleAndFixedAccessory))) container.addArrangedSubview(createButton(title: "Show with pill segmented control", action: #selector(showLargeTitleWithSystemStyleAndPillSegment))) - addTitle(text: "Regular Title") - container.addArrangedSubview(createButton(title: "Show \"system\" with collapsible search bar", action: #selector(showRegularTitleWithShyAccessory))) + addTitle(text: "Leading with TwoLineTitleView") + container.addArrangedSubview(createButton(title: "Show with fixed search bar and subtitle", action: #selector(showLeadingTitleWithFixedAccessoryAndSubtitle))) + container.addArrangedSubview(createButton(title: "Show with collapsible search bar and subtitle", action: #selector(showLeadingTitleWithSystemStyleShyAccessoryAndSubtitle))) + container.addArrangedSubview(createButton(title: "Show with custom leading button", action: #selector(showLeadingTitleWithSubtitleAndCustomLeadingButton))) + + addTitle(text: "Centered Title") + container.addArrangedSubview(createButton(title: "Show \"system\"", action: #selector(showSystemTitle))) + container.addArrangedSubview(createButton(title: "Show \"primary\" with subtitle", action: #selector(showRegularTitleWithSubtitle))) + container.addArrangedSubview(createButton(title: "Show \"system\" with collapsible search bar", action: #selector(showSystemTitleWithShyAccessory))) + container.addArrangedSubview(createButton(title: "Show \"primary\" with collapsible search bar and subtitle", action: #selector(showRegularTitleWithShyAccessoryAndSubtitle))) container.addArrangedSubview(createButton(title: "Show \"primary\" with fixed search bar", action: #selector(showRegularTitleWithFixedAccessory))) + container.addArrangedSubview(createButton(title: "Show \"system\" with fixed search bar and subtitle", action: #selector(showSystemTitleWithFixedAccessoryAndSubtitle))) + container.addArrangedSubview(createButton(title: "Show \"primary\" with custom leading button", action: #selector(showRegularTitleWithSubtitleAndCustomLeadingButton))) addTitle(text: "Size Customization") container.addArrangedSubview(createButton(title: "Show with expanded avatar, contracted title", action: #selector(showLargeTitleWithCustomizedElementSizes))) @@ -44,82 +55,126 @@ class NavigationControllerDemoController: DemoController { } @objc func showLargeTitle() { - presentController(withLargeTitle: true) + presentController(withTitleStyle: .largeLeading) } @objc func showLargeTitleWithShyAccessory() { - presentController(withLargeTitle: true, accessoryView: createAccessoryView(), contractNavigationBarOnScroll: true) + presentController(withTitleStyle: .largeLeading, accessoryView: createAccessoryView(), contractNavigationBarOnScroll: true) } @objc func showLargeTitleWithFixedAccessory() { - presentController(withLargeTitle: true, accessoryView: createAccessoryView(), contractNavigationBarOnScroll: false) + presentController(withTitleStyle: .largeLeading, accessoryView: createAccessoryView(), contractNavigationBarOnScroll: false) } @objc func showLargeTitleWithSystemStyle() { - presentController(withLargeTitle: true, style: .system) + presentController(withTitleStyle: .largeLeading, style: .system) } @objc func showLargeTitleWithSystemStyleAndNoShadow() { - presentController(withLargeTitle: true, style: .system, contractNavigationBarOnScroll: false, showShadow: false) + presentController(withTitleStyle: .largeLeading, style: .system, contractNavigationBarOnScroll: false, showShadow: false) } @objc func showLargeTitleWithSystemStyleAndShyAccessory() { - presentController(withLargeTitle: true, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: true) + presentController(withTitleStyle: .largeLeading, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: true) } @objc func showLargeTitleWithSystemStyleAndFixedAccessory() { - presentController(withLargeTitle: true, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: false) + presentController(withTitleStyle: .largeLeading, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: false) } @objc func showLargeTitleWithSystemStyleAndPillSegment() { - presentController(withLargeTitle: true, style: .system, accessoryView: createSegmentedControl(), contractNavigationBarOnScroll: false) + presentController(withTitleStyle: .largeLeading, style: .system, accessoryView: createSegmentedControl(compatibleWith: .system), contractNavigationBarOnScroll: false) + } + + @objc func showLeadingTitleWithFixedAccessoryAndSubtitle() { + presentController(withTitleStyle: .leading, subtitle: "Subtitle goes here", accessoryView: createAccessoryView(), contractNavigationBarOnScroll: false) + } + + @objc func showLeadingTitleWithSystemStyleShyAccessoryAndSubtitle() { + presentController(withTitleStyle: .leading, subtitle: "Subtitle goes here", style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: true) } - @objc func showRegularTitleWithShyAccessory() { - presentController(withLargeTitle: false, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: true) + @objc func showLeadingTitleWithSubtitleAndCustomLeadingButton() { + presentController(withTitleStyle: .leading, subtitle: "Subtitle goes here", style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: true, leadingItem: .customButton) + } + + @objc func showSystemTitleWithShyAccessory() { + presentController(withTitleStyle: .system, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: true) + } + + @objc func showRegularTitleWithShyAccessoryAndSubtitle() { + presentController(withTitleStyle: .system, subtitle: "Subtitle goes here", accessoryView: createAccessoryView(), contractNavigationBarOnScroll: true) } @objc func showRegularTitleWithFixedAccessory() { - presentController(withLargeTitle: false, accessoryView: createAccessoryView(), contractNavigationBarOnScroll: false) + presentController(withTitleStyle: .system, accessoryView: createAccessoryView()) + } + + @objc func showSystemTitleWithFixedAccessoryAndSubtitle() { + presentController(withTitleStyle: .system, subtitle: "Subtitle goes here", style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: false) + } + + @objc func showSystemTitle() { + presentController(withTitleStyle: .system, style: .system) + } + + @objc func showRegularTitleWithSubtitle() { + presentController(withTitleStyle: .system, subtitle: "Subtitle goes here") + } + + @objc func showRegularTitleWithSubtitleAndCustomLeadingButton() { + presentController(withTitleStyle: .system, subtitle: "Subtitle goes here", style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), contractNavigationBarOnScroll: true, leadingItem: .customButton) } @objc func showLargeTitleWithCustomizedElementSizes() { - let controller = presentController(withLargeTitle: true, accessoryView: createAccessoryView()) + let controller = presentController(withTitleStyle: .largeLeading, accessoryView: createAccessoryView()) controller.msfNavigationBar.avatarSize = .expanded controller.msfNavigationBar.titleSize = .contracted } @objc func showLargeTitleWithCustomizedColor() { - presentController(withLargeTitle: true, style: .custom, accessoryView: createAccessoryView()) + presentController(withTitleStyle: .largeLeading, style: .custom, accessoryView: createAccessoryView()) } @objc func showLargeTitleWithoutAvatar() { - presentController(withLargeTitle: true, style: .primary, accessoryView: createAccessoryView(), showAvatar: false) + presentController(withTitleStyle: .largeLeading, style: .primary, accessoryView: createAccessoryView(), leadingItem: .nothing) + } + + @objc func showLargeTitleWithCustomLeadingButton() { + presentController(withTitleStyle: .largeLeading, style: .primary, accessoryView: createAccessoryView(), leadingItem: .customButton) } @objc func showWithTopSearchBar() { - presentController(withLargeTitle: true, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), showsTopAccessory: true, contractNavigationBarOnScroll: false) + presentController(withTitleStyle: .largeLeading, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), showsTopAccessory: true, contractNavigationBarOnScroll: false) } @objc func showSearchChangingStyleEverySecond() { - presentController(withLargeTitle: true, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), showsTopAccessory: true, contractNavigationBarOnScroll: false, updateStylePeriodically: true) + presentController(withTitleStyle: .largeLeading, style: .system, accessoryView: createAccessoryView(with: .onSystemNavigationBar), showsTopAccessory: true, contractNavigationBarOnScroll: false, updateStylePeriodically: true) } @objc func showLargeTitleWithPillSegment() { - presentController(withLargeTitle: true, accessoryView: createSegmentedControl(), contractNavigationBarOnScroll: false) + presentController(withTitleStyle: .largeLeading, accessoryView: createSegmentedControl(compatibleWith: .primary), contractNavigationBarOnScroll: false) } + private enum LeadingItem { + case nothing + case avatar + case customButton + } @discardableResult - private func presentController(withLargeTitle useLargeTitle: Bool, + private func presentController(withTitleStyle titleStyle: NavigationBar.TitleStyle, + subtitle: String? = nil, style: NavigationBar.Style = .primary, accessoryView: UIView? = nil, showsTopAccessory: Bool = false, contractNavigationBarOnScroll: Bool = true, showShadow: Bool = true, - showAvatar: Bool = true, + leadingItem: LeadingItem = .avatar, updateStylePeriodically: Bool = false) -> NavigationController { let content = RootViewController() - content.navigationItem.usesLargeTitle = useLargeTitle + content.navigationItem.titleStyle = titleStyle + content.navigationItem.subtitle = subtitle + content.navigationItem.backButtonTitle = "99+" content.navigationItem.navigationBarStyle = style content.navigationItem.navigationBarShadow = showShadow ? .automatic : .alwaysHidden content.navigationItem.accessoryView = accessoryView @@ -134,10 +189,15 @@ class NavigationControllerDemoController: DemoController { } let controller = NavigationController(rootViewController: content) - if showAvatar { + switch leadingItem { + case .avatar: controller.msfNavigationBar.personaData = content.personaData controller.msfNavigationBar.onAvatarTapped = handleAvatarTapped - } else { + case .customButton: + let starButtonItem = UIBarButtonItem(image: UIImage(named: "ic_fluent_star_24_regular")) + starButtonItem.accessibilityLabel = "Star button" + content.navigationItem.leftBarButtonItem = starButtonItem + case .nothing: content.allowsCellSelection = true } @@ -146,7 +206,7 @@ class NavigationControllerDemoController: DemoController { } controller.modalPresentationStyle = .fullScreen - if useLargeTitle { + if titleStyle.usesLeadingAlignment { let leadingEdgeGesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleScreenEdgePan)) leadingEdgeGesture.edges = view.effectiveUserInterfaceLayoutDirection == .leftToRight ? .left : .right leadingEdgeGesture.delegate = self @@ -185,11 +245,11 @@ class NavigationControllerDemoController: DemoController { return searchBar } - private func createSegmentedControl() -> UIView { + private func createSegmentedControl(compatibleWith style: NavigationBar.Style) -> UIView { let segmentItems: [SegmentItem] = [ SegmentItem(title: "First"), SegmentItem(title: "Second")] - let pillControl = SegmentedControl(items: segmentItems, style: .onBrandPill) + let pillControl = SegmentedControl(items: segmentItems, style: style == .system ? .neutralOverNavBarPill : .brandOverNavBarPill) pillControl.shouldSetEqualWidthForSegments = false pillControl.isFixedWidth = false pillControl.contentInset = .zero @@ -243,7 +303,70 @@ extension NavigationControllerDemoController: UIGestureRecognizerDelegate { // MARK: - RootViewController -class RootViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { +class RootViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, NavigationBarTitleAccessoryDelegate { + struct TitleViewFeature { + var name: String + var apply: (ChildViewController) -> Void + } + + lazy var titleViewFeaturesByRow: [Int: TitleViewFeature] = [ + 4: TitleViewFeature(name: "Large title") { + $0.navigationItem.titleStyle = .largeLeading + }, + 5: TitleViewFeature(name: "Leading-aligned, two titles, collapsible") { + $0.navigationItem.titleStyle = .leading + $0.navigationItem.subtitle = "Subtitle" + $0.navigationItem.contentScrollView = $0.tableView + }, + 6: TitleViewFeature(name: "Two titles with subtitle disclosure") { + $0.navigationItem.subtitle = "Press me!" + $0.navigationItem.titleAccessory = NavigationBarTitleAccessory(location: .subtitle, style: .disclosure, delegate: self) + }, + 7: TitleViewFeature(name: "Leading-aligned, image, subtitle") { + $0.navigationItem.titleStyle = .leading + $0.navigationItem.titleImage = UIImage(named: "ic_fluent_star_16_regular") + $0.navigationItem.subtitle = "Subtitle" + }, + 8: TitleViewFeature(name: "Centered, image, subtitle") { + $0.navigationItem.titleImage = UIImage(named: "ic_fluent_star_16_regular") + $0.navigationItem.subtitle = "Subtitle" + }, + 9: TitleViewFeature(name: "Leading-aligned, image, down arrow, subtitle") { + $0.navigationItem.titleStyle = .leading + $0.navigationItem.titleImage = UIImage(named: "ic_fluent_star_16_regular") + $0.navigationItem.subtitle = "Subtitle" + $0.navigationItem.titleAccessory = NavigationBarTitleAccessory(location: .title, style: .downArrow, delegate: self) + }, + 10: TitleViewFeature(name: "Centered, image, down arrow, subtitle") { + $0.navigationItem.titleImage = UIImage(named: "ic_fluent_star_16_regular") + $0.navigationItem.subtitle = "Subtitle" + $0.navigationItem.titleAccessory = NavigationBarTitleAccessory(location: .title, style: .downArrow, delegate: self) + }, + 11: TitleViewFeature(name: "Leading, down arrow") { + $0.navigationItem.titleStyle = .leading + $0.navigationItem.titleAccessory = NavigationBarTitleAccessory(location: .title, style: .downArrow, delegate: self) + }, + 12: TitleViewFeature(name: "Centered, down arrow") { + $0.navigationItem.titleAccessory = NavigationBarTitleAccessory(location: .title, style: .downArrow, delegate: self) + }, + 13: TitleViewFeature(name: "Leading, image, disclosure") { + $0.navigationItem.titleStyle = .leading + $0.navigationItem.titleImage = UIImage(named: "ic_fluent_star_16_regular") + $0.navigationItem.titleAccessory = NavigationBarTitleAccessory(location: .title, style: .disclosure, delegate: self) + }, + 14: TitleViewFeature(name: "Centered, image, disclosure") { + $0.navigationItem.titleImage = UIImage(named: "ic_fluent_star_16_regular") + $0.navigationItem.titleAccessory = NavigationBarTitleAccessory(location: .title, style: .disclosure, delegate: self) + }, + 15: TitleViewFeature(name: "Centered, collapsible search bar") { + let searchBar = SearchBar() + searchBar.style = $0.navigationItem.navigationBarStyle == .system ? .onSystemNavigationBar : .onBrandNavigationBar + searchBar.placeholderText = "Search" + $0.navigationItem.accessoryView = searchBar + $0.navigationItem.contentScrollView = $0.tableView + } + ] + enum BarButtonItemTag: Int { case dismiss case select @@ -399,7 +522,7 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe guard let cell = tableView.dequeueReusableCell(withIdentifier: BooleanCell.identifier, for: indexPath) as? BooleanCell else { return UITableViewCell() } - let isSwitchEnabled = navigationItem.usesLargeTitle && msfNavigationController?.msfNavigationBar.personaData != nil + let isSwitchEnabled = navigationItem.titleStyle == .largeLeading && msfNavigationController?.msfNavigationBar.personaData != nil cell.setup(title: "Show rainbow ring on avatar", isOn: showRainbowRingForAvatar, isSwitchEnabled: isSwitchEnabled) @@ -416,7 +539,7 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe } cell.setup(title: "Show badge on right bar button items", isOn: showBadgeOnBarButtonItem, - isSwitchEnabled: navigationItem.usesLargeTitle) + isSwitchEnabled: navigationItem.titleStyle == .largeLeading) cell.titleNumberOfLines = 0 cell.onValueChanged = { [weak self, weak cell] in self?.shouldShowBadge(isOn: cell?.isOn ?? false) @@ -438,7 +561,8 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe return UITableViewCell() } let imageView = UIImageView(image: UIImage(named: "excelIcon")) - cell.setup(title: "Cell #\(indexPath.row)", customView: imageView, accessoryType: .disclosureIndicator) + let row = indexPath.row + cell.setup(title: "Cell #\(row)", subtitle: titleViewFeaturesByRow[row]?.name ?? "", customView: imageView, accessoryType: .disclosureIndicator) cell.isInSelectionMode = isInSelectionMode return cell } @@ -447,10 +571,14 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe if isInSelectionMode { updateNavigationTitle() } else { - let controller = ChildViewController() + let row = indexPath.row + let controller = ChildViewController(parentIndex: row) if navigationItem.accessoryView == nil { controller.navigationItem.navigationBarStyle = .system } + if let feature = titleViewFeaturesByRow[row] { + feature.apply(controller) + } navigationController?.pushViewController(controller, animated: true) } } @@ -470,7 +598,7 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe let selectedCount = tableView.indexPathsForSelectedRows?.count ?? 0 navigationItem.title = selectedCount == 1 ? "1 item selected" : "\(selectedCount) items selected" } else { - navigationItem.title = navigationItem.usesLargeTitle ? "Large Title" : "Regular Title" + navigationItem.title = navigationItem.titleStyle == .largeLeading ? "Large Title" : "Regular Title" } } @@ -611,6 +739,13 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe return customBorderImage } + + func navigationBarDidTapOnTitle(_ sender: NavigationBar) { + if let topItem = sender.topItem { + topItem.navigationBarStyle = topItem.navigationBarStyle == .primary ? .system : .primary + setNeedsStatusBarAppearanceUpdate() + } + } } // MARK: - RootViewController: SearchBarDelegate @@ -641,11 +776,19 @@ extension RootViewController: SearchBarDelegate { // MARK: - ChildViewController class ChildViewController: UITableViewController { + var parentIndex: Int = -1 + + convenience init(parentIndex: Int) { + self.init() + self.parentIndex = parentIndex + } + override func viewDidLoad() { super.viewDidLoad() tableView.contentInsetAdjustmentBehavior = .never tableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.identifier) - navigationItem.title = "Regular Title" + navigationItem.title = "Cell #\(parentIndex)" + navigationItem.backButtonTitle = "\(parentIndex)" } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -660,6 +803,50 @@ class ChildViewController: UITableViewController { return cell } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let controller = GrandchildViewController(grandparentIndex: parentIndex, parentIndex: 1 + indexPath.row) + if navigationItem.accessoryView == nil { + controller.navigationItem.navigationBarStyle = .system + } + navigationController?.pushViewController(controller, animated: true) + } + + override func tableView(_ tableView: UITableView, canFocusRowAt indexPath: IndexPath) -> Bool { + return true + } +} + +// MARK: - GrandchildViewController + +class GrandchildViewController: UITableViewController { + var grandparentIndex: Int = -1 + var parentIndex: Int = -1 + + convenience init(grandparentIndex: Int, parentIndex: Int) { + self.init() + self.grandparentIndex = grandparentIndex + self.parentIndex = parentIndex + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.contentInsetAdjustmentBehavior = .never + tableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.identifier) + navigationItem.title = "Cell #\(grandparentIndex)-\(parentIndex)" + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 100 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.identifier, for: indexPath) as? TableViewCell else { + return UITableViewCell() + } + cell.setup(title: "Grandchild Cell #\(1 + indexPath.row)") + return cell + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) } @@ -773,3 +960,41 @@ class CustomGradient { return UIColor(light: image != nil ? UIColor(patternImage: image!) : endColor, dark: UIColor(colorValue: GlobalTokens.neutralColors(.grey16))) } } + +extension NavigationControllerDemoController: DemoAppearanceDelegate { + func themeWideOverrideDidChange(isOverrideEnabled: Bool) { + guard let window = self.view.window else { + return + } + let fluentTheme = window.fluentTheme + + fluentTheme.register(tokenSetType: NavigationBarTokenSet.self, + tokenSet: isOverrideEnabled ? themeWideOverrideTokens(fluentTheme) : nil) + } + + func perControlOverrideDidChange(isOverrideEnabled: Bool) { + // Ignored since we don't have any navigation controllers spawned when this gets toggled + } + + func isThemeWideOverrideApplied() -> Bool { + return self.view.window?.fluentTheme.tokens(for: NavigationBarTokenSet.self) != nil + } + + private func themeWideOverrideTokens(_ theme: FluentTheme) -> [NavigationBarTokenSet.Tokens: ControlTokenValue] { + return [ + .titleColor: .uiColor { + UIColor(light: GlobalTokens.sharedColor(.hotPink, .primary), + dark: GlobalTokens.sharedColor(.hotPink, .tint30)) + }, + .titleFont: .uiFont { theme.typography(.caption1Strong) }, + .subtitleColor: .uiColor { + UIColor(light: GlobalTokens.sharedColor(.lime, .primary), + dark: GlobalTokens.sharedColor(.lime, .tint30)) + }, + .buttonTintColor: .uiColor { + UIColor(light: GlobalTokens.sharedColor(.orange, .primary), + dark: GlobalTokens.sharedColor(.orange, .tint30)) + } + ] + } +} diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/SearchBarDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/SearchBarDemoController.swift index c66c12c38c..1609d357b8 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos/SearchBarDemoController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/SearchBarDemoController.swift @@ -35,17 +35,17 @@ class SearchBarDemoController: DemoController, SearchBarDelegate { let segmentedControl = SegmentedControl(items: [SegmentItem(title: "OnCanvas"), SegmentItem(title: "OnNavigationBar"), SegmentItem(title: "OnBrand")], - style: .primaryPill) + style: .neutralOverNavBarPill) return segmentedControl }() @objc private func updateSearchbars() { if segmentedControl.selectedSegmentIndex == 2 { - searchBarsStackView.backgroundColor = NavigationBar.Style.primary.backgroundColor(fluentTheme: view.fluentTheme) + searchBarsStackView.backgroundColor = NavigationBar.backgroundColor(for: .primary, theme: view.fluentTheme) updateSearchBarsStyles(to: .onBrandNavigationBar) } else if segmentedControl.selectedSegmentIndex == 1 { - searchBarsStackView.backgroundColor = NavigationBar.Style.system.backgroundColor(fluentTheme: view.fluentTheme) + searchBarsStackView.backgroundColor = NavigationBar.backgroundColor(for: .system, theme: view.fluentTheme) updateSearchBarsStyles(to: .onSystemNavigationBar) } else { searchBarsStackView.backgroundColor = UIColor(light: view.fluentTheme.color(.background5).light, diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/TwoLineTitleViewDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/TwoLineTitleViewDemoController.swift new file mode 100644 index 0000000000..217e573b75 --- /dev/null +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/TwoLineTitleViewDemoController.swift @@ -0,0 +1,227 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import FluentUI +import UIKit + +class TwoLineTitleViewDemoController: DemoController { + private typealias UINavigationItemModifier = (UINavigationItem) -> Void + private typealias TwoLineTitleViewFactory = (_ forBottomSheet: Bool) -> TwoLineTitleView + + private static func createDemoTitleView(forBottomSheet: Bool) -> TwoLineTitleView { + let twoLineTitleView = TwoLineTitleView() + + if !forBottomSheet { + // Give it a visible margin so we can confirm it centers properly + twoLineTitleView.widthAnchor.constraint(equalToConstant: 200).isActive = true + twoLineTitleView.layer.borderWidth = GlobalTokens.stroke(.width10) + twoLineTitleView.layer.borderColor = GlobalTokens.neutralColor(.grey50).cgColor + } + + return twoLineTitleView + } + + // Return a function that returns the title view because we may end up calling it multiple times in case of bottom sheets + private static func makeStandardTitleView(title: String, + titleImage: UIImage? = nil, + subtitle: String? = nil, + alignment: TwoLineTitleView.Alignment = .center, + interactivePart: TwoLineTitleView.InteractivePart = .none, + animatesWhenPressed: Bool = true, + accessoryType: TwoLineTitleView.AccessoryType = .none) -> TwoLineTitleViewFactory { + return { + let twoLineTitleView = createDemoTitleView(forBottomSheet: $0) + twoLineTitleView.setup(title: title, + titleImage: titleImage, + subtitle: subtitle, + alignment: alignment, + interactivePart: interactivePart, + animatesWhenPressed: animatesWhenPressed, + accessoryType: accessoryType) + return twoLineTitleView + } + } + + private static func makeExampleNavigationItem(_ initializer: UINavigationItemModifier) -> UINavigationItem { + let navigationItem = UINavigationItem() + initializer(navigationItem) + return navigationItem + } + + private let exampleSetupFactories: [TwoLineTitleViewFactory] = [ + makeStandardTitleView(title: "Title here", subtitle: "Optional subtitle", animatesWhenPressed: false), + makeStandardTitleView(title: "Custom image", titleImage: UIImage(named: "ic_fluent_star_16_regular"), animatesWhenPressed: false), + makeStandardTitleView(title: "This one", subtitle: "can be tapped", interactivePart: .all), + makeStandardTitleView(title: "All the bells", titleImage: UIImage(named: "ic_fluent_star_16_regular"), subtitle: "and whistles", alignment: .leading, interactivePart: .subtitle, accessoryType: .downArrow) + ] + + private let exampleNavigationItems: [UINavigationItem] = [ + makeExampleNavigationItem { + $0.title = "Title here" + }, + makeExampleNavigationItem { + $0.title = "Another title" + $0.subtitle = "With a subtitle" + }, + makeExampleNavigationItem { + $0.title = "This one" + $0.subtitle = "has an image" + $0.titleImage = UIImage(named: "ic_fluent_star_16_regular") + }, + makeExampleNavigationItem { + $0.title = "This one" + $0.subtitle = "has a disclosure chevron" + $0.titleAccessory = .init(location: .title, style: .disclosure) + }, + makeExampleNavigationItem { + $0.title = "They can also be" + $0.subtitle = "leading-aligned" + $0.titleStyle = .leading + } + ] + + private var allExamples: [TwoLineTitleView] = [] + + override func viewDidLoad() { + super.viewDidLoad() + readmeString = "TwoLineTitleView is intended to be used in a navigation bar or the title of a sheet. It features the ability to show custom icons, a disclosure chevron, and other things.\n\nYou can also populate a bottom sheet with a TwoLineTitleView." + + container.alignment = .leading + + addTitle(text: "Made by calling TwoLineTitleView.setup(...)") + exampleSetupFactories.enumerated().forEach { + (offset, element) in + let twoLineTitleView = element(false) + allExamples.append(twoLineTitleView) + + let button = Button() + button.tag = offset + button.setTitle("Show", for: .normal) + button.addTarget(self, action: #selector(setupButtonWasTapped), for: .primaryActionTriggered) + + addRow(items: [twoLineTitleView, button]) + } + + addTitle(text: "Made from UINavigationItem") + addTitle(text: "(requires Navigation subspec)") + exampleNavigationItems.enumerated().forEach { + (offset, navigationItem) in + let twoLineTitleView = Self.createDemoTitleView(forBottomSheet: false) + twoLineTitleView.setup(navigationItem: navigationItem) + allExamples.append(twoLineTitleView) + + let button = Button() + button.tag = offset + button.setTitle("Show", for: .normal) + button.addTarget(self, action: #selector(navigationButtonWasTapped), for: .primaryActionTriggered) + + addRow(items: [twoLineTitleView, button]) + } + } + + @objc private func navigationButtonWasTapped(sender: UIButton) { + let titleView = TwoLineTitleView() + titleView.setup(navigationItem: exampleNavigationItems[sender.tag]) + showBottomSheet(with: titleView) + } + + @objc private func setupButtonWasTapped(sender: UIButton) { + let titleView = exampleSetupFactories[sender.tag](true) + showBottomSheet(with: titleView) + } + + private func showBottomSheet(with titleView: TwoLineTitleView) { + let sheetContentView = UIView() + + // This is the bottom sheet that will temporarily be displayed after tapping the "Show transient sheet" button. + // There can be multiple of these on screen at the same time. All the currently presented transient sheets + // are tracked in presentedTransientSheets. + let secondarySheetController = BottomSheetController(headerContentView: titleView, expandedContentView: sheetContentView) + secondarySheetController.headerContentHeight = 44 + secondarySheetController.collapsedContentHeight = 100 + secondarySheetController.isHidden = true + secondarySheetController.shouldAlwaysFillWidth = false + secondarySheetController.shouldHideCollapsedContent = false + secondarySheetController.isFlexibleHeight = true + secondarySheetController.allowsSwipeToHide = true + + let dismissButton = Button(primaryAction: UIAction(title: "Dismiss", handler: { _ in + secondarySheetController.setIsHidden(true, animated: true) + })) + + dismissButton.style = .accent + dismissButton.translatesAutoresizingMaskIntoConstraints = false + sheetContentView.addSubview(dismissButton) + + addChild(secondarySheetController) + view.addSubview(secondarySheetController.view) + secondarySheetController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + secondarySheetController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + secondarySheetController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + secondarySheetController.view.topAnchor.constraint(equalTo: view.topAnchor), + secondarySheetController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + dismissButton.leadingAnchor.constraint(equalTo: sheetContentView.leadingAnchor, constant: 18), + dismissButton.trailingAnchor.constraint(equalTo: sheetContentView.trailingAnchor, constant: -18), + dismissButton.bottomAnchor.constraint(equalTo: sheetContentView.safeAreaLayoutGuide.bottomAnchor) + ]) + + // We need to layout before unhiding to ensure the sheet controller + // has a meaningful initial frame to use for the animation. + view.layoutIfNeeded() + secondarySheetController.isHidden = false + } +} + +extension TwoLineTitleViewDemoController: DemoAppearanceDelegate { + func themeWideOverrideDidChange(isOverrideEnabled: Bool) { + guard let fluentTheme = self.view.window?.fluentTheme else { + return + } + + fluentTheme.register(tokenSetType: TwoLineTitleViewTokenSet.self, + tokenSet: isOverrideEnabled ? themeWideOverrideTokens : nil) + } + + func perControlOverrideDidChange(isOverrideEnabled: Bool) { + allExamples.forEach { + $0.tokenSet.replaceAllOverrides(with: isOverrideEnabled ? perControlOverrideTokens : nil) + } + } + + func isThemeWideOverrideApplied() -> Bool { + return self.view.window?.fluentTheme.tokens(for: TwoLineTitleViewTokenSet.self) != nil + } + + private var themeWideOverrideTokens: [TwoLineTitleViewTokenSet.Tokens: ControlTokenValue] { + return [ + .titleColor: .uiColor { + UIColor(light: GlobalTokens.sharedColor(.green, .primary), + dark: GlobalTokens.sharedColor(.green, .tint30)) + }, + .titleFont: .uiFont { UIFont(descriptor: .init(name: "Verdana", size: 17), size: 17) }, + .subtitleColor: .uiColor { + UIColor(light: GlobalTokens.sharedColor(.red, .primary), + dark: GlobalTokens.sharedColor(.red, .tint30)) + } + ] + } + + private var perControlOverrideTokens: [TwoLineTitleViewTokenSet.Tokens: ControlTokenValue] { + return [ + .titleColor: .uiColor { + UIColor(light: GlobalTokens.sharedColor(.blue, .primary), + dark: GlobalTokens.sharedColor(.blue, .tint30)) + }, + .titleFont: .uiFont { UIFont(descriptor: .init(name: "Papyrus", size: 12), size: 12) }, + .subtitleColor: .uiColor { + UIColor(light: GlobalTokens.sharedColor(.orange, .primary), + dark: GlobalTokens.sharedColor(.orange, .tint30)) + }, + .subtitleFont: .uiFont { UIFont(descriptor: .init(name: "Papyrus", size: 10), size: 10) } + ] + } +} diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_16_regular.imageset/Contents.json b/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_16_regular.imageset/Contents.json new file mode 100644 index 0000000000..dd5e8bc368 --- /dev/null +++ b/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_16_regular.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_star_16_regular.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_16_regular.imageset/ic_fluent_star_16_regular.pdf b/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_16_regular.imageset/ic_fluent_star_16_regular.pdf new file mode 100644 index 0000000000..3f5d914615 Binary files /dev/null and b/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_16_regular.imageset/ic_fluent_star_16_regular.pdf differ diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_24_regular.imageset/Contents.json b/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_24_regular.imageset/Contents.json new file mode 100644 index 0000000000..844994743a --- /dev/null +++ b/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_24_regular.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_star_24_regular.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_24_regular.imageset/ic_fluent_star_24_regular.pdf b/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_24_regular.imageset/ic_fluent_star_24_regular.pdf new file mode 100644 index 0000000000..f234a50376 Binary files /dev/null and b/ios/FluentUI.Demo/FluentUI.Demo/Resources/Assets.xcassets/Navigation/ic_fluent_star_24_regular.imageset/ic_fluent_star_24_regular.pdf differ diff --git a/ios/FluentUI.xcodeproj/project.pbxproj b/ios/FluentUI.xcodeproj/project.pbxproj index 4f0dad0adf..b3ee937048 100644 --- a/ios/FluentUI.xcodeproj/project.pbxproj +++ b/ios/FluentUI.xcodeproj/project.pbxproj @@ -73,7 +73,7 @@ 5314E0ED25F012C40099271A /* ContentScrollViewTraits.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C86E22DD13230086F899 /* ContentScrollViewTraits.swift */; }; 5314E0F225F012C80099271A /* ShyHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C87122DD13230086F899 /* ShyHeaderView.swift */; }; 5314E0F325F012C80099271A /* ShyHeaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C87022DD13230086F899 /* ShyHeaderController.swift */; }; - 5314E0F825F012CB0099271A /* LargeTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C87A22DD13230086F899 /* LargeTitleView.swift */; }; + 5314E0F825F012CB0099271A /* AvatarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD41C87A22DD13230086F899 /* AvatarTitleView.swift */; }; 5314E10A25F014600099271A /* Obscurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53BCB0CD253A4E8C00620960 /* Obscurable.swift */; }; 5314E11625F015EA0099271A /* PersonaBadgeViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BA27872319DC0D0001563C /* PersonaBadgeViewDataSource.swift */; }; 5314E11725F015EA0099271A /* PersonaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46D3F922151D95F0029772C /* PersonaCell.swift */; }; @@ -144,6 +144,10 @@ 53E2EE0527860D010086D30D /* MSFActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E2EE0427860D010086D30D /* MSFActivityIndicator.swift */; }; 53E2EE07278799B30086D30D /* MSFIndeterminateProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E2EE06278799B30086D30D /* MSFIndeterminateProgressBar.swift */; }; 566C664A28CB99830032314C /* module.modulemap in Copy module.modulemap */ = {isa = PBXBuildFile; fileRef = 566C664828CB97210032314C /* module.modulemap */; }; + 66512A3829D3B30D003CF303 /* AvatarTitleViewTokenSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66512A3729D3B30D003CF303 /* AvatarTitleViewTokenSet.swift */; }; + 667E54022A12B6F800728F93 /* TwoLineTitleView+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667E54012A12B6F800728F93 /* TwoLineTitleView+Navigation.swift */; }; + 66963D0A29CA7F89006F5FA9 /* TwoLineTitleViewTokenSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66963D0929CA7F89006F5FA9 /* TwoLineTitleViewTokenSet.swift */; }; + 66963D1029CE244D006F5FA9 /* NavigationBarTokenSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66963D0F29CE244D006F5FA9 /* NavigationBarTokenSet.swift */; }; 6EB4B25F270ED6B30005B808 /* BadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB4B25D270ED6450005B808 /* BadgeLabel.swift */; }; 6ED5E55126D3D39400D8BE81 /* BadgeLabelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED4C11C2696AE4000C30BD6 /* BadgeLabelButton.swift */; }; 6ED5E55226D3D39400D8BE81 /* UIBarButtonItem+BadgeValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED4C11A2695A6E800C30BD6 /* UIBarButtonItem+BadgeValue.swift */; }; @@ -301,6 +305,10 @@ 53FC90F925673627008A06FD /* FluentUILib_common.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FluentUILib_common.xcconfig; sourceTree = ""; }; 53FC90FA25673627008A06FD /* FluentUIResources.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FluentUIResources.xcconfig; sourceTree = ""; }; 566C664828CB97210032314C /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; + 66512A3729D3B30D003CF303 /* AvatarTitleViewTokenSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarTitleViewTokenSet.swift; sourceTree = ""; }; + 667E54012A12B6F800728F93 /* TwoLineTitleView+Navigation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwoLineTitleView+Navigation.swift"; sourceTree = ""; }; + 66963D0929CA7F89006F5FA9 /* TwoLineTitleViewTokenSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwoLineTitleViewTokenSet.swift; sourceTree = ""; }; + 66963D0F29CE244D006F5FA9 /* NavigationBarTokenSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarTokenSet.swift; sourceTree = ""; }; 6EB4B25D270ED6450005B808 /* BadgeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeLabel.swift; sourceTree = ""; }; 6ED4C11A2695A6E800C30BD6 /* UIBarButtonItem+BadgeValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+BadgeValue.swift"; sourceTree = ""; }; 6ED4C11C2696AE4000C30BD6 /* BadgeLabelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeLabelButton.swift; sourceTree = ""; }; @@ -432,7 +440,7 @@ FD41C86E22DD13230086F899 /* ContentScrollViewTraits.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentScrollViewTraits.swift; sourceTree = ""; }; FD41C87022DD13230086F899 /* ShyHeaderController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShyHeaderController.swift; sourceTree = ""; }; FD41C87122DD13230086F899 /* ShyHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShyHeaderView.swift; sourceTree = ""; }; - FD41C87A22DD13230086F899 /* LargeTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LargeTitleView.swift; sourceTree = ""; }; + FD41C87A22DD13230086F899 /* AvatarTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarTitleView.swift; sourceTree = ""; }; FD41C87B22DD13230086F899 /* NavigationBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = ""; }; FD41C87E22DD13230086F899 /* SearchBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; FD41C87F22DD13230086F899 /* NavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationController.swift; sourceTree = ""; }; @@ -709,6 +717,7 @@ isa = PBXGroup; children = ( FD5BBE40214C6AF3008964B4 /* TwoLineTitleView.swift */, + 66963D0929CA7F89006F5FA9 /* TwoLineTitleViewTokenSet.swift */, ); path = TwoLineTitleView; sourceTree = ""; @@ -1169,7 +1178,9 @@ children = ( FD41C87F22DD13230086F899 /* NavigationController.swift */, FD41C87B22DD13230086F899 /* NavigationBar.swift */, + 66963D0F29CE244D006F5FA9 /* NavigationBarTokenSet.swift */, 6ED4C11C2696AE4000C30BD6 /* BadgeLabelButton.swift */, + 667E54012A12B6F800728F93 /* TwoLineTitleView+Navigation.swift */, FD41C8BD22DD47120086F899 /* UINavigationItem+Navigation.swift */, FD9DA7B4232C33A80013E41B /* UIViewController+Navigation.swift */, 6ED4C11A2695A6E800C30BD6 /* UIBarButtonItem+BadgeValue.swift */, @@ -1202,7 +1213,8 @@ FD41C87922DD13230086F899 /* Views */ = { isa = PBXGroup; children = ( - FD41C87A22DD13230086F899 /* LargeTitleView.swift */, + FD41C87A22DD13230086F899 /* AvatarTitleView.swift */, + 66512A3729D3B30D003CF303 /* AvatarTitleViewTokenSet.swift */, ); path = Views; sourceTree = ""; @@ -1508,6 +1520,7 @@ 923DB9D4274CB65700D8E58A /* TokenizedControl.swift in Sources */, 5314E06B25F00F100099271A /* DateTimePicker.swift in Sources */, 5314E2BB25F024C60099271A /* CALayer+Extensions.swift in Sources */, + 66512A3829D3B30D003CF303 /* AvatarTitleViewTokenSet.swift in Sources */, 5314E12B25F016230099271A /* PillButtonBar.swift in Sources */, 5314E07E25F00F1A0099271A /* DateTimePickerView.swift in Sources */, 5328D97726FBA3D700F3723B /* IndeterminateProgressBar.swift in Sources */, @@ -1536,7 +1549,7 @@ C708B064260A87F7007190FA /* SegmentItem.swift in Sources */, 5328D97126FBA3D700F3723B /* IndeterminateProgressBarTokenSet.swift in Sources */, 5314E14325F016860099271A /* CardTransitionAnimator.swift in Sources */, - 5314E0F825F012CB0099271A /* LargeTitleView.swift in Sources */, + 5314E0F825F012CB0099271A /* AvatarTitleView.swift in Sources */, 8035CAB62633A4DB007B3FD1 /* BottomCommandingController.swift in Sources */, 5314E13725F016370099271A /* PopupMenuProtocols.swift in Sources */, 5314E19725F019650099271A /* TabBarItem.swift in Sources */, @@ -1567,6 +1580,7 @@ 92EE82AE27025A94009D52B5 /* TokenSet.swift in Sources */, 6F3CB7F229D3A5DE00284353 /* ResizingHandleTokenSet.swift in Sources */, 5314E28125F0240D0099271A /* DateComponents+Extensions.swift in Sources */, + 667E54022A12B6F800728F93 /* TwoLineTitleView+Navigation.swift in Sources */, 6F3CB7F029D3A2B700284353 /* DrawerTokenSet.swift in Sources */, 0A8E61FB291DC11F009E412D /* CommandBarTokenSet.swift in Sources */, 5314E07025F00F140099271A /* DatePickerController.swift in Sources */, @@ -1654,6 +1668,7 @@ 5314E08A25F00F2D0099271A /* CommandBar.swift in Sources */, 9231491128BF026A001B033E /* HeadsUpDisplayTokenSet.swift in Sources */, 5314E18D25F0195C0099271A /* ShimmerView.swift in Sources */, + 66963D1029CE244D006F5FA9 /* NavigationBarTokenSet.swift in Sources */, 80AECC22263339E5005AF2F3 /* BottomSheetPassthroughView.swift in Sources */, 925728F9276D6B5800EE1019 /* FontInfo.swift in Sources */, 5314E1CD25F01B730099271A /* AnimationSynchronizer.swift in Sources */, @@ -1672,6 +1687,7 @@ 92DEE2252723D34400E31ED0 /* ControlTokenSet.swift in Sources */, 5314E0E425F012C00099271A /* NavigationController.swift in Sources */, 925728F7276D6AF800EE1019 /* ShadowInfo.swift in Sources */, + 66963D0A29CA7F89006F5FA9 /* TwoLineTitleViewTokenSet.swift in Sources */, 5328D97326FBA3D700F3723B /* IndeterminateProgressBarModifiers.swift in Sources */, 923DB9D7274CB66D00D8E58A /* ControlHostingView.swift in Sources */, 5314E03B25F00E3D0099271A /* BadgeStringExtractor.swift in Sources */, diff --git a/ios/FluentUI/Core/Theme/FluentTheme+Tokens.swift b/ios/FluentUI/Core/Theme/FluentTheme+Tokens.swift index 59c1370d40..f10658caf8 100644 --- a/ios/FluentUI/Core/Theme/FluentTheme+Tokens.swift +++ b/ios/FluentUI/Core/Theme/FluentTheme+Tokens.swift @@ -153,6 +153,7 @@ public extension FluentTheme { /// Returns the font value for the given token. /// /// - Parameter token: The `TypographyTokens` value to be retrieved. + /// - Parameter adjustsForContentSizeCategory: If true, the resulting font will change size according to Dynamic Type specifications. /// - Returns: A `FontInfo` for the given token. @objc(typographyForToken:adjustsForContentSizeCategory:) func typography(_ token: TypographyToken, adjustsForContentSizeCategory: Bool = true) -> UIFont { diff --git a/ios/FluentUI/Core/Theme/Tokens/ControlTokenSet.swift b/ios/FluentUI/Core/Theme/Tokens/ControlTokenSet.swift index 3dd33db59d..10976a7a7f 100644 --- a/ios/FluentUI/Core/Theme/Tokens/ControlTokenSet.swift +++ b/ios/FluentUI/Core/Theme/Tokens/ControlTokenSet.swift @@ -53,6 +53,22 @@ public class ControlTokenSet: ObservableObject { } } + /// Convenience method to override multiple tokens from another token set. + /// + /// This is useful if `otherTokenSet` belongs to a parent control and `self` belongs to a child control. + /// + /// - Parameter otherTokenSet: The token set we will be pulling values from. + /// - Parameter mapping: A `Dictionary` that maps our own tokens that we wish to override with + /// their corresponding tokens in `otherTokenSet`. + func setOverrides(from otherTokenSet: ControlTokenSet, mapping: [T: U]) { + // Make a copy so we write all the values at once + var valueOverrideCopy = valueOverrides ?? [:] + mapping.forEach { (thisToken, otherToken) in + valueOverrideCopy[thisToken] = otherTokenSet.overrideValue(forToken: otherToken) + } + valueOverrides = valueOverrideCopy + } + /// Initialize the `ControlTokenSet` with an escaping callback for fetching default values. init(_ defaults: @escaping (_ token: T, _ theme: FluentTheme) -> ControlTokenValue) { self.defaults = defaults diff --git a/ios/FluentUI/EasyTapButton/EasyTapButton.swift b/ios/FluentUI/EasyTapButton/EasyTapButton.swift index ac87b558d9..b93ccac2b4 100644 --- a/ios/FluentUI/EasyTapButton/EasyTapButton.swift +++ b/ios/FluentUI/EasyTapButton/EasyTapButton.swift @@ -9,7 +9,9 @@ import UIKit @objc(MSFEasyTapButton) open class EasyTapButton: UIButton { - var minTapTargetSize = CGSize(width: 44.0, height: 44.0) + static let minimumTouchSize = CGSize(width: 44, height: 44) + + var minTapTargetSize: CGSize = minimumTouchSize open override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let growX = max(0, (minTapTargetSize.width - bounds.size.width) / 2) diff --git a/ios/FluentUI/Label/Label.swift b/ios/FluentUI/Label/Label.swift index 511b89cbd0..8409dedfd2 100644 --- a/ios/FluentUI/Label/Label.swift +++ b/ios/FluentUI/Label/Label.swift @@ -10,8 +10,15 @@ import UIKit /// By default, `adjustsFontForContentSizeCategory` is set to true to automatically update its font when device's content size category changes @objc(MSFLabel) open class Label: UILabel, TokenizedControlInternal { - @objc open var colorStyle: TextColorStyle = .regular { - didSet { + private static let defaultColorForTheme: (FluentTheme) -> UIColor = TextColorStyle.regular.uiColor + + @objc open var colorStyle: TextColorStyle { + @available(*, unavailable) + get { + preconditionFailure("colorStyle is now a write-only property") + } + set { + colorForTheme = newValue.uiColor updateTextColor() } } @@ -80,10 +87,12 @@ open class Label: UILabel, TokenizedControlInternal { lazy public var tokenSet: LabelTokenSet = .init(textStyle: { [weak self] in return self?.textStyle ?? .body1 }, - colorStyle: { [weak self] in - return self?.colorStyle ?? .regular + colorForTheme: { [weak self] theme in + return (self?.colorForTheme ?? Self.defaultColorForTheme)(theme) }) + private var colorForTheme: (FluentTheme) -> UIColor = Label.defaultColorForTheme + @objc convenience public init() { self.init(textStyle: .body1, colorStyle: .regular) } @@ -102,6 +111,13 @@ open class Label: UILabel, TokenizedControlInternal { initialize() } + @objc public init(textStyle: FluentTheme.TypographyToken = .body1, colorForTheme: @escaping (FluentTheme) -> UIColor) { + super.init(frame: .zero) + self.textStyle = textStyle + self.colorForTheme = colorForTheme + initialize() + } + @objc public required init?(coder aDecoder: NSCoder) { preconditionFailure("init(coder:) has not been implemented") } diff --git a/ios/FluentUI/Label/LabelTokenSet.swift b/ios/FluentUI/Label/LabelTokenSet.swift index 1315411bec..5f20020c6b 100644 --- a/ios/FluentUI/Label/LabelTokenSet.swift +++ b/ios/FluentUI/Label/LabelTokenSet.swift @@ -14,6 +14,21 @@ public enum TextColorStyle: Int, CaseIterable { case white case primary case error + + func uiColor(fluentTheme: FluentTheme) -> UIColor { + switch self { + case .regular: + return fluentTheme.color(.foreground1) + case .secondary: + return fluentTheme.color(.foreground2) + case .white: + return fluentTheme.color(.foregroundLightStatic) + case .primary: + return fluentTheme.color(.brandForeground1) + case .error: + return fluentTheme.color(.dangerForeground2) + } + } } public class LabelTokenSet: ControlTokenSet { @@ -22,11 +37,16 @@ public class LabelTokenSet: ControlTokenSet { case textColor } + convenience init(textStyle: @escaping () -> FluentTheme.TypographyToken, + colorStyle: @escaping () -> TextColorStyle) { + self.init(textStyle: textStyle, colorForTheme: { colorStyle().uiColor(fluentTheme: $0) }) + } + init(textStyle: @escaping () -> FluentTheme.TypographyToken, - colorStyle: @escaping () -> TextColorStyle) { + colorForTheme: @escaping (FluentTheme) -> UIColor) { self.textStyle = textStyle - self.colorStyle = colorStyle - super.init { [colorStyle] token, theme in + self.colorForTheme = colorForTheme + super.init { [colorForTheme] token, theme in switch token { case .font: return .uiFont { @@ -34,18 +54,7 @@ public class LabelTokenSet: ControlTokenSet { } case .textColor: return .uiColor { - switch colorStyle() { - case .regular: - return theme.color(.foreground1) - case .secondary: - return theme.color(.foreground2) - case .white: - return theme.color(.foregroundLightStatic) - case .primary: - return theme.color(.brandForeground1) - case .error: - return theme.color(.dangerForeground2) - } + return colorForTheme(theme) } } } @@ -53,6 +62,6 @@ public class LabelTokenSet: ControlTokenSet { /// Defines the text typography style of the label. var textStyle: () -> FluentTheme.TypographyToken - /// Defines the text color style of the label. - var colorStyle: () -> TextColorStyle + /// Defines the text color style of the label for a given theme. + var colorForTheme: (FluentTheme) -> UIColor } diff --git a/ios/FluentUI/Navigation/BadgeLabelButton.swift b/ios/FluentUI/Navigation/BadgeLabelButton.swift index 319cbcb383..2e2522a489 100644 --- a/ios/FluentUI/Navigation/BadgeLabelButton.swift +++ b/ios/FluentUI/Navigation/BadgeLabelButton.swift @@ -33,6 +33,10 @@ class BadgeLabelButton: UIButton { selector: #selector(badgeValueDidChange), name: UIBarButtonItem.badgeValueDidChangeNotification, object: item) + NotificationCenter.default.addObserver(self, + selector: #selector(contentSizeCategoryDidChange(notification:)), + name: UIContentSizeCategory.didChangeNotification, + object: nil) } required init?(coder aDecoder: NSCoder) { @@ -53,6 +57,10 @@ class BadgeLabelButton: UIButton { static let badgeBorderWidth: CGFloat = 2 static let badgeHorizontalPadding: CGFloat = 10 static let badgeCornerRadii: CGFloat = 10 + + // These are consistent with UIKit's default navigation bar buttons + static let maximumContentSizeCategory: UIContentSizeCategory = .extraExtraLarge + static let minimumContentSizeCategory: UIContentSizeCategory = .large } private let badgeLabel = BadgeLabel() @@ -191,7 +199,7 @@ class BadgeLabelButton: UIButton { width: computedBadgeWidth, height: Constants.badgeHeight) let badgeCutoutPath = UIBezierPath(rect: CGRect(x: badgeBoundsOriginX, - y: 0, + y: badgeBounds.origin.y, width: frame.size.width + computedBadgeWidth / 2, height: frame.size.height)) // Adding the path for the cutout on the button's titleLabel or imageView where the badge label will be placed on top of. @@ -223,6 +231,31 @@ class BadgeLabelButton: UIButton { updateAccessibilityLabel() } + @objc private func contentSizeCategoryDidChange(notification: Notification) { + guard let titleLabel = titleLabel else { + return + } + + let requestedContentSizeCategory = (notification.userInfo?[UIContentSizeCategory.newValueUserInfoKey] as? UIContentSizeCategory) ?? .unspecified + + let cappedContentSizeCategory: UIContentSizeCategory + if requestedContentSizeCategory > Constants.maximumContentSizeCategory { + cappedContentSizeCategory = Constants.maximumContentSizeCategory + } else if requestedContentSizeCategory < Constants.minimumContentSizeCategory { + cappedContentSizeCategory = Constants.minimumContentSizeCategory + } else { + cappedContentSizeCategory = requestedContentSizeCategory + } + + // For some reason, titleLabel doesn't resize to fit the new font size, so we do it ourselves. + titleLabel.font = UIFont.fluent(fluentTheme.aliasTokens.typography[.body1], contentSizeCategory: cappedContentSizeCategory) + titleLabel.sizeToFit() + sizeToFit() + if superview != nil { + centerInSuperview(horizontally: false, vertically: true) + } + } + private func updateAccessibilityLabel() { guard let item = item else { return diff --git a/ios/FluentUI/Navigation/NavigationBar.swift b/ios/FluentUI/Navigation/NavigationBar.swift index 162c5369da..06ce48cd79 100644 --- a/ios/FluentUI/Navigation/NavigationBar.swift +++ b/ios/FluentUI/Navigation/NavigationBar.swift @@ -47,13 +47,59 @@ open class NavigationBarTopSearchBarAttributes: NavigationBarTopAccessoryViewAtt } } +// MARK: - NavigationBarTitleAccessory + +@objc(MSFNavigationBarTitleAccessoryDelegate) +/// Handles user interactions with a `NavigationBar` with an accessory. +public protocol NavigationBarTitleAccessoryDelegate { + @objc func navigationBarDidTapOnTitle(_ sender: NavigationBar) +} + +/// The specifications for an accessory to show in the title or subtitle of the navigation bar. +@objc(MSFNavigationBarTitleAccessory) +open class NavigationBarTitleAccessory: NSObject { + /// Specifies a location where the title accessory should appear within the navigation bar. + @objc(MSFNavigationBarTitleAccessoryLocation) + public enum Location: Int { + case title + case subtitle + } + + /// The style of title accessory to show. + @objc(MSFNavigationBarTitleAccessoryStyle) + public enum Style: Int { + case disclosure + case downArrow + } + + /// The location of the accessory. + public let location: Location + /// The style of the accessory. + public let style: Style + /// A delegate that handles title press actions. + public weak var delegate: NavigationBarTitleAccessoryDelegate? + + public init(location: Location, style: Style, delegate: NavigationBarTitleAccessoryDelegate? = nil) { + self.location = location + self.style = style + self.delegate = delegate + } +} + +// MARK: - NavigationBarBackButtonDelegate +/// Handles presses from the back button shown with a leading-aligned title. +@objc(MSFNavigationBarBackButtonDelegate) +protocol NavigationBarBackButtonDelegate { + func backButtonWasPressed() +} + // MARK: - NavigationBar /// UINavigationBar subclass, with a content view that contains various custom UIElements /// Contains the MSNavigationTitleView class and handles passing animatable progress through /// Custom UI can be hidden if desired @objc(MSFNavigationBar) -open class NavigationBar: UINavigationBar, TokenizedControlInternal { +open class NavigationBar: UINavigationBar, TokenizedControlInternal, TwoLineTitleViewDelegate { /// If the style is `.custom`, UINavigationItem's `navigationBarColor` is used for all the subviews' backgroundColor @objc(MSFNavigationBarStyle) public enum Style: Int { @@ -61,43 +107,25 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { case primary case system case custom + } - func tintColor(fluentTheme: FluentTheme) -> UIColor { - switch self { - case .primary, .default, .custom: - return UIColor(light: fluentTheme.color(.foregroundOnColor).light, - dark: fluentTheme.color(.foreground2).dark) - case .system: - return fluentTheme.color(.foreground2) - } - } + @objc(MSFNavigationBarTitleStyle) + /// Describes the style in which the title is shown in a navigation bar. + public enum TitleStyle: Int { + /// Shows a center-aligned title and/or subtitle. Most closely aligned with UIKit's default. Not capable of showing an avatar. + case system + /// Shows a leading-aligned title and/or subtitle. Also capable of showing an avatar. + case leading + /// Shows a large title. This option always ignores the subtitle. Also capable of showing an avatar. + case largeLeading - func titleColor(fluentTheme: FluentTheme) -> UIColor { - switch self { - case .primary, .default, .custom: - return UIColor(light: fluentTheme.color(.foregroundOnColor).light, - dark: fluentTheme.color(.foreground1).dark) - case .system: - return fluentTheme.color(.foreground1) - } - } - - public func backgroundColor(fluentTheme: FluentTheme, customColor: UIColor? = nil) -> UIColor { - let defaultColor = UIColor(light: fluentTheme.color(.brandBackground1).light, - dark: fluentTheme.color(.background3).dark) - switch self { - case .primary, .default: - return defaultColor - case .system: - return fluentTheme.color(.background3) - case .custom: - return customColor ?? defaultColor - } + public var usesLeadingAlignment: Bool { + self != .system } } - @objc public static func navigationBarBackgroundColor(fluentTheme: FluentTheme) -> UIColor { - return Style.system.backgroundColor(fluentTheme: fluentTheme) + @objc public static func navigationBarBackgroundColor(fluentTheme: FluentTheme?) -> UIColor { + return backgroundColor(for: .system, theme: fluentTheme) } /// Describes the sizing behavior of navigation bar elements (title, avatar, bar height) @@ -112,24 +140,14 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { case alwaysHidden } - public typealias TokenSetKeyType = EmptyTokenSet.Tokens - public var tokenSet: EmptyTokenSet = .init() + public typealias TokenSetKeyType = NavigationBarTokenSet.Tokens + public lazy var tokenSet: NavigationBarTokenSet = .init(style: { [weak self] in + self?.style ?? NavigationBar.defaultStyle + }) static let expansionContractionAnimationDuration: TimeInterval = 0.1 // the interval over which the expansion/contraction animations occur - private static var defaultStyle: Style = .primary - - private struct Constants { - static let systemHeight: CGFloat = 44 - static let normalContentHeight: CGFloat = 44 - static let expandedContentHeight: CGFloat = 48 - - static let leftBarButtonItemLeadingMargin: CGFloat = 8 - static let rightBarButtonItemHorizontalPadding: CGFloat = 10 - - static let obscuringAnimationDuration: TimeInterval = 0.12 - static let revealingAnimationDuration: TimeInterval = 0.25 - } + private static let defaultStyle: Style = .primary /// An object that conforms to the `MSFPersona` protocol and provides text and an optional image for display as an `MSAvatar` next to the large title. Only displayed if `showsLargeTitle` is true on the current navigation item. If avatar is nil, it won't show the avatar view. @objc open var personaData: Persona? { @@ -162,7 +180,7 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { /// Returns the first match of an optional view for a bar button item with the given tag. @objc public func barButtonItemView(with tag: Int) -> UIView? { - if showsLargeTitle { + if usesLeadingTitle { let totalBarButtonItemViews = leftBarButtonItemsStackView.arrangedSubviews + rightBarButtonItemsStackView.arrangedSubviews for view in totalBarButtonItemViews { if view.tag == tag { @@ -254,7 +272,7 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { } } - var titleView = LargeTitleView() { + var titleView = AvatarTitleView() { willSet { titleView.removeFromSuperview() } @@ -268,19 +286,26 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { // @objc dynamic - so we can do KVO on this @objc dynamic private(set) var style: Style = defaultStyle - let backgroundView = UIView() //used for coloration - //used to cover the navigationbar during animated transitions between VCs - private let contentStackView = ContentStackView() //used to contain the various custom UI Elements + private var systemWantsCompactNavigationBar: Bool { + return traitCollection.horizontalSizeClass == .compact && traitCollection.verticalSizeClass == .compact + } + + let backgroundView = UIView() // used for coloration + // used to cover the navigationbar during animated transitions between VCs + private let contentStackView = ContentStackView() // used to contain the various custom UI Elements private let rightBarButtonItemsStackView = UIStackView() private let leftBarButtonItemsStackView = UIStackView() - private let leadingSpacerView = UIView() //defines the leading space between the left and right barbuttonitems stack - private let trailingSpacerView = UIView() //defines the trailing space between the left and right barbuttonitems stack + private let preTitleSpacerView = UIView() // defines the spacing before the title, used for compact centered titles + private let postTitleSpacerView = UIView() // defines the spacing after the title, also the leading space between the left and right barbuttonitems stack + private let trailingSpacerView = UIView() // defines the trailing space between the left and right barbuttonitems stack private var topAccessoryView: UIView? private var topAccessoryViewConstraints: [NSLayoutConstraint] = [] - private var showsLargeTitle: Bool = true { + private var titleViewConstraint: NSLayoutConstraint? + + private(set) var usesLeadingTitle: Bool = true { didSet { - if showsLargeTitle == oldValue { + if usesLeadingTitle == oldValue { return } updateAccessibilityElements() @@ -291,13 +316,24 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { private var leftBarButtonItemsObserver: NSKeyValueObservation? private var rightBarButtonItemsObserver: NSKeyValueObservation? private var titleObserver: NSKeyValueObservation? + private var subtitleObserver: NSKeyValueObservation? + private var titleAccessoryObserver: NSKeyValueObservation? + private var titleImageObserver: NSKeyValueObservation? private var navigationBarColorObserver: NSKeyValueObservation? private var accessoryViewObserver: NSKeyValueObservation? private var topAccessoryViewObserver: NSKeyValueObservation? private var topAccessoryViewAttributesObserver: NSKeyValueObservation? private var navigationBarStyleObserver: NSKeyValueObservation? private var navigationBarShadowObserver: NSKeyValueObservation? - private var usesLargeTitleObserver: NSKeyValueObservation? + private var titleStyleObserver: NSKeyValueObservation? + + private let backButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage.staticImageNamed("back-24x24"), style: .plain, target: nil, action: #selector(NavigationBarBackButtonDelegate.backButtonWasPressed)) + + weak var backButtonDelegate: NavigationBarBackButtonDelegate? { + didSet { + backButtonItem.target = backButtonDelegate + } + } @objc public override init(frame: CGRect) { super.init(frame: frame) @@ -322,29 +358,35 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { updateContentStackViewMargins(forExpandedContent: true) contentStackView.addInteraction(UILargeContentViewerInteraction()) - //leftBarButtonItemsStackView: layout priorities are slightly lower to make sure titleView has the highest priority in horizontal spacing + // leftBarButtonItemsStackView: layout priorities are slightly lower to make sure titleView has the highest priority in horizontal spacing contentStackView.addArrangedSubview(leftBarButtonItemsStackView) leftBarButtonItemsStackView.setContentHuggingPriority(.defaultHigh, for: .horizontal) leftBarButtonItemsStackView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - //titleView + // preTitleSpacerView + contentStackView.addArrangedSubview(preTitleSpacerView) + preTitleSpacerView.backgroundColor = .clear + preTitleSpacerView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + preTitleSpacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + // titleView contentStackView.addArrangedSubview(titleView) titleView.setContentHuggingPriority(.required, for: .horizontal) titleView.setContentCompressionResistancePriority(.required, for: .horizontal) - //leadingSpacerView - contentStackView.addArrangedSubview(leadingSpacerView) - leadingSpacerView.backgroundColor = .clear - leadingSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) - leadingSpacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + // postTitleSpacerView + contentStackView.addArrangedSubview(postTitleSpacerView) + postTitleSpacerView.backgroundColor = .clear + postTitleSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + postTitleSpacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - //trailingSpacerView + // trailingSpacerView contentStackView.addArrangedSubview(trailingSpacerView) trailingSpacerView.backgroundColor = .clear trailingSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) trailingSpacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - //rightBarButtonItemsStackView: layout priorities are slightly lower to make sure titleView has the highest priority in horizontal spacing + // rightBarButtonItemsStackView: layout priorities are slightly lower to make sure titleView has the highest priority in horizontal spacing contentStackView.addArrangedSubview(rightBarButtonItemsStackView) rightBarButtonItemsStackView.setContentHuggingPriority(.defaultHigh, for: .horizontal) rightBarButtonItemsStackView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) @@ -360,6 +402,7 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { tokenSet.registerOnUpdate(for: self) { [weak self] in self?.updateColors(for: self?.topItem) + self?.updateTitleViewTokenSets() } } @@ -373,7 +416,7 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { if let topAccessoryView = self.topAccessoryView { topAccessoryView.translatesAutoresizingMaskIntoConstraints = false - let insertionIndex = contentStackView.arrangedSubviews.firstIndex(of: leadingSpacerView)! + 1 + let insertionIndex = contentStackView.arrangedSubviews.firstIndex(of: postTitleSpacerView)! + 1 contentStackView.insertArrangedSubview(topAccessoryView, at: insertionIndex) NSLayoutConstraint.deactivate(topAccessoryViewConstraints) @@ -423,14 +466,20 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { bottom.priority = .defaultHigh NSLayoutConstraint.activate([leading, trailing, top, bottom]) + + // These are consistent with UIKit's default navigation bar + contentStackView.minimumContentSizeCategory = .large + contentStackView.maximumContentSizeCategory = .extraExtraLarge } private func updateContentStackViewMargins(forExpandedContent contentIsExpanded: Bool) { - let contentHeight = contentIsExpanded ? Constants.expandedContentHeight : Constants.normalContentHeight + let contentHeight = contentIsExpanded ? TokenSetType.expandedContentHeight : TokenSetType.normalContentHeight + let systemHeight = systemWantsCompactNavigationBar ? TokenSetType.compactSystemHeight : TokenSetType.systemHeight + contentStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( top: 0, leading: contentLeadingMargin, - bottom: -(contentHeight - Constants.systemHeight), + bottom: systemHeight - contentHeight, trailing: contentTrailingMargin ) } @@ -440,10 +489,23 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { guard let newWindow else { return } + tokenSet.update(newWindow.fluentTheme) + + updateTitleViewTokenSets() updateColors(for: topItem) } + private func updateTitleViewTokenSets() { + titleView.tokenSet.setOverrides(from: tokenSet, mapping: [ + .titleColor: .titleColor, + .titleFont: .titleFont, + .subtitleColor: .subtitleColor, + .subtitleFont: .subtitleFont, + .largeTitleFont: .largeTitleFont + ]) + } + /// Guarantees that the custom UI remains on top of the subview stack /// Fetches the current navigation item and triggers a UI update open override func layoutSubviews() { @@ -464,10 +526,15 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { if traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass { updateElementSizes() updateContentStackViewMargins(forExpandedContent: contentIsExpanded) + updateViewsForLargeTitlePresentation(for: topItem) + updateFakeCenterTitleConstraints() - // change bar button image size depending on device rotation - if showsLargeTitle, let navigationItem = topItem { - updateBarButtonItems(with: navigationItem) + // change bar button image size and title inset depending on device rotation + if let navigationItem = topItem { + updateSubtitleView(for: navigationItem) + if usesLeadingTitle { + updateBarButtonItems(with: navigationItem) + } } } } @@ -508,7 +575,6 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { private func updateElementSizes() { titleView.avatarSize = currentAvatarSize - titleView.titleSize = currentTitleSize barHeight = currentBarHeight } @@ -526,9 +592,9 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { standardAppearance.backgroundColor = color backgroundView.backgroundColor = color - tintColor = style.tintColor(fluentTheme: tokenSet.fluentTheme) - standardAppearance.titleTextAttributes[NSAttributedString.Key.foregroundColor] = style.titleColor(fluentTheme: tokenSet.fluentTheme) - standardAppearance.largeTitleTextAttributes[NSAttributedString.Key.foregroundColor] = style.titleColor(fluentTheme: tokenSet.fluentTheme) + tintColor = tokenSet[.buttonTintColor].uiColor + standardAppearance.titleTextAttributes[NSAttributedString.Key.foregroundColor] = tokenSet[.titleColor].uiColor + standardAppearance.largeTitleTextAttributes[NSAttributedString.Key.foregroundColor] = tokenSet[.titleColor].uiColor // Update the scroll edge appearance to match the new standard appearance scrollEdgeAppearance = standardAppearance @@ -543,13 +609,18 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { let (actualStyle, actualItem) = actualStyleAndItem(for: navigationItem) style = actualStyle updateColors(for: actualItem) - showsLargeTitle = navigationItem.usesLargeTitle + usesLeadingTitle = navigationItem.titleStyle.usesLeadingAlignment updateShadow(for: navigationItem) updateTopAccessoryView(for: navigationItem) + updateSubtitleView(for: navigationItem) titleView.update(with: navigationItem) - navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) + updateFakeCenterTitleConstraints() + + if navigationItem.backButtonTitle == nil { + navigationItem.backButtonTitle = "" + } updateBarButtonItems(with: navigationItem) // Force layout to avoid animation @@ -564,6 +635,15 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { titleObserver = navigationItem.observe(\UINavigationItem.title) { [unowned self] item, _ in self.navigationItemDidUpdate(item) } + subtitleObserver = navigationItem.observe(\UINavigationItem.subtitle) { [unowned self] item, _ in + self.navigationItemDidUpdate(item) + } + titleAccessoryObserver = navigationItem.observe(\UINavigationItem.titleAccessory) { [unowned self] item, _ in + self.navigationItemDidUpdate(item) + } + titleImageObserver = navigationItem.observe(\UINavigationItem.titleImage) { [unowned self] item, _ in + self.navigationItemDidUpdate(item) + } accessoryViewObserver = navigationItem.observe(\UINavigationItem.accessoryView) { [unowned self] item, _ in self.navigationItemDidUpdate(item) } @@ -579,7 +659,7 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { navigationBarShadowObserver = navigationItem.observe(\UINavigationItem.navigationBarShadow) { [unowned self] item, _ in self.navigationItemDidUpdate(item) } - usesLargeTitleObserver = navigationItem.observe(\UINavigationItem.usesLargeTitle) { [unowned self] item, _ in + titleStyleObserver = navigationItem.observe(\UINavigationItem.titleStyle) { [unowned self] item, _ in self.navigationItemDidUpdate(item) } } @@ -600,42 +680,49 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { button.item = item button.shouldUseWindowColorInBadge = style != .system - if #available(iOS 15.0, *) { - let insets: NSDirectionalEdgeInsets - if isLeftItem { - insets = NSDirectionalEdgeInsets(top: 0, - leading: Constants.leftBarButtonItemLeadingMargin, - bottom: 0, - trailing: 0) - } else { - insets = NSDirectionalEdgeInsets(top: 0, - leading: Constants.rightBarButtonItemHorizontalPadding, - bottom: 0, - trailing: Constants.rightBarButtonItemHorizontalPadding) - } + let horizontalInset = isLeftItem ? TokenSetType.leftBarButtonItemHorizontalInset : TokenSetType.rightBarButtonItemHorizontalInset + let insets = NSDirectionalEdgeInsets(top: 0, + leading: horizontalInset, + bottom: 0, + trailing: horizontalInset) - button.configuration?.contentInsets = insets - } else { - if isLeftItem { - let isRTL = effectiveUserInterfaceLayoutDirection == .rightToLeft - button.contentEdgeInsets = UIEdgeInsets(top: 0, - left: isRTL ? 0 : Constants.leftBarButtonItemLeadingMargin, - bottom: 0, - right: isRTL ? Constants.leftBarButtonItemLeadingMargin : 0) - } else { - button.contentEdgeInsets = UIEdgeInsets(top: 0, - left: Constants.rightBarButtonItemHorizontalPadding, - bottom: 0, - right: Constants.rightBarButtonItemHorizontalPadding) - } - } + button.configuration?.contentInsets = insets return button } + /// Updates the bar button items. + /// + /// In general, this should be called as late as possible when receiving a new navigation item + /// because it will replace a client-provided left bar button item with a back button if needed. private func updateBarButtonItems(with navigationItem: UINavigationItem) { // only one left bar button item is support for large title view - if let leftBarButtonItem = navigationItem.leftBarButtonItem { + if navigationItem != items?.first { + // Back button takes priority over client-provided leftBarButtonItem + // navigationItem != items?.first is sufficient for knowing we won't be at the + // root element of our navigation controller. This is because UINavigationItems + // are unique to their view controllers, and you can't push the same view controller + // onto a navigation stack more than once. + leftBarButtonItemsStackView.isHidden = false + + // This gets called before the navigation stack gets updated + if let items = items, let navigationItemIndex = items.firstIndex(of: navigationItem), navigationItemIndex > 0 { + let upcomingBackItem = items[navigationItemIndex - 1] + backButtonItem.title = upcomingBackItem.backButtonTitle + } else { + // Assume that this item is getting pushed onto the stack + backButtonItem.title = topItem?.backButtonTitle + } + + if navigationItem.titleStyle == .system { + let button = createBarButtonItemButton(with: backButtonItem, isLeftItem: true) + // The OS already gives us the leading margin we want, so no need for additional insets + button.configuration?.contentInsets.leading = 0 + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: button) + } + + refresh(barButtonStack: leftBarButtonItemsStackView, with: [backButtonItem], isLeftItem: true) + } else if let leftBarButtonItem = navigationItem.leftBarButtonItem { leftBarButtonItemsStackView.isHidden = false refresh(barButtonStack: leftBarButtonItemsStackView, with: [leftBarButtonItem], isLeftItem: true) } else { @@ -662,7 +749,7 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { func obscureContent(animated: Bool) { if contentStackView.alpha == 1 { if animated { - UIView.animate(withDuration: Constants.obscuringAnimationDuration) { + UIView.animate(withDuration: TokenSetType.obscuringAnimationDuration) { self.contentStackView.alpha = 0 } } else { @@ -674,7 +761,7 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { func revealContent(animated: Bool) { if contentStackView.alpha == 0 { if animated { - UIView.animate(withDuration: Constants.revealingAnimationDuration) { + UIView.animate(withDuration: TokenSetType.revealingAnimationDuration) { self.contentStackView.alpha = 1 } } else { @@ -692,7 +779,12 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { // UIView.isHidden has a bug where a series of repeated calls with the same parameter can "glitch" the view into a permanent shown/hidden state // i.e. repeatedly trying to hide a UIView that is already in the hidden state // by adding a check to the isHidden property prior to setting, we avoid such problematic scenarios - if showsLargeTitle { + + // The compact (32px) bar doesn't hold a TwoLineTitleView very well, and due to + // UINavigationBar's internal view hierarchy, we can't propagate touch events on + // parts that are outside that 32px range to the actual title view. + // We therefore depend on the "fake" navigation bar that we use for leading titles to save the day. + if usesLeadingTitle || systemWantsCompactNavigationBar { if backgroundView.isHidden { backgroundView.isHidden = false } @@ -713,6 +805,21 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { updateShadow(for: navigationItem) } + private func updateFakeCenterTitleConstraints() { + titleViewConstraint?.isActive = false + + let newTitleViewConstraint: NSLayoutConstraint + if !usesLeadingTitle && systemWantsCompactNavigationBar { + // If we're drawing our own system-style bar above the OS bar, align our title with the OS's + newTitleViewConstraint = titleView.centerXAnchor.constraint(equalTo: centerXAnchor) + } else { + // Otherwise, keep `self.titleView` leading-justified + newTitleViewConstraint = preTitleSpacerView.widthAnchor.constraint(equalToConstant: 0) + } + titleViewConstraint = newTitleViewConstraint + newTitleViewConstraint.isActive = true + } + private func updateShadow(for navigationItem: UINavigationItem?) { if needsShadow(for: navigationItem) { standardAppearance.shadowColor = systemShadowColor @@ -727,12 +834,35 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { private func needsShadow(for navigationItem: UINavigationItem?) -> Bool { switch navigationItem?.navigationBarShadow ?? .automatic { case .automatic: - return !showsLargeTitle && style == .system && navigationItem?.accessoryView == nil + return !usesLeadingTitle && style == .system && !systemWantsCompactNavigationBar && navigationItem?.accessoryView == nil case .alwaysHidden: return false } } + private func updateSubtitleView(for navigationItem: UINavigationItem?) { + guard let navigationItem = navigationItem, !usesLeadingTitle else { + // Use the default title view + navigationItem?.titleView = nil + return + } + + let customTitleView = TwoLineTitleView(style: style == .primary ? .primary : .system) + customTitleView.tokenSet.setOverrides(from: tokenSet, mapping: [ + .titleColor: .titleColor, + .titleFont: .titleFont, + .subtitleColor: .subtitleColor, + .subtitleFont: .subtitleFont + ]) + customTitleView.setup(navigationItem: navigationItem) + if navigationItem.titleAccessory == nil { + // Use default behavior of requesting an accessory expansion + customTitleView.delegate = self + } + + navigationItem.titleView = customTitleView + } + // MARK: Content expansion/contraction private var isExpanded: Bool = true @@ -782,12 +912,19 @@ open class NavigationBar: UINavigationBar, TokenizedControlInternal { // MARK: Accessibility private func updateAccessibilityElements() { - if showsLargeTitle { + if usesLeadingTitle { accessibilityElements = contentStackView.arrangedSubviews } else { accessibilityElements = nil } } + + // MARK: TwoLineTitleViewDelegate + + /// Tapping the regular two-line title view asks the accessory to expand. + public func twoLineTitleViewDidTapOnTitle(_ twoLineTitleView: TwoLineTitleView) { + NotificationCenter.default.post(name: .accessoryExpansionRequested, object: self) + } } // MARK: - ContentStackView diff --git a/ios/FluentUI/Navigation/NavigationBarTokenSet.swift b/ios/FluentUI/Navigation/NavigationBarTokenSet.swift new file mode 100644 index 0000000000..a8d319aefd --- /dev/null +++ b/ios/FluentUI/Navigation/NavigationBarTokenSet.swift @@ -0,0 +1,104 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +/// Design token set for the `NavigationBar` control. +public class NavigationBarTokenSet: ControlTokenSet { + public enum Tokens: TokenSetKey { + /// Describes the background color for the navigation bar. + case backgroundColor + + /// Describes the color of the buttons at the top of the navigation bar. + case buttonTintColor + + /// Describes the font used for a "large" title. + case largeTitleFont + + /// Describes the color of the subtitle. + case subtitleColor + + /// Describes the font used for the subtitle. + case subtitleFont + + /// Describes the color of the title. + case titleColor + + /// Describes the font used for a "small" title. + case titleFont + } + + init(style: @escaping () -> NavigationBar.Style) { + super.init { [style] token, theme in + switch token { + case .backgroundColor: + return .uiColor { + switch style() { + case .primary, .default, .custom: + return UIColor(light: theme.color(.brandBackground1).light, + dark: theme.color(.background3).dark) + case .system: + return theme.color(.background3) + } + } + case .buttonTintColor, .subtitleColor: // By default, these return the same color + return .uiColor { + switch style() { + case .primary, .default, .custom: + return UIColor(light: theme.color(.foregroundOnColor).light, + dark: theme.color(.foreground2).dark) + case .system: + return theme.color(.foreground2) + } + } + case .largeTitleFont: + return .uiFont { + theme.typography(.title1) + } + case .subtitleFont: + return .uiFont { + theme.typography(.caption1) + } + case .titleColor: + return .uiColor { + switch style() { + case .primary, .default, .custom: + return UIColor(light: theme.color(.foregroundOnColor).light, + dark: theme.color(.foreground1).dark) + case .system: + return theme.color(.foreground1) + } + } + case .titleFont: + return .uiFont { + theme.typography(.body1Strong) + } + } + } + } +} + +public extension NavigationBar { + static func backgroundColor(for style: Style, theme: FluentTheme?) -> UIColor { + let tokenSet = TokenSetType { style } + tokenSet.fluentTheme = theme ?? .shared + return tokenSet[.backgroundColor].uiColor + } +} + +extension NavigationBarTokenSet { + // These two constants are based on OS default values + static let systemHeight: CGFloat = 44 + static let compactSystemHeight: CGFloat = 32 + + static let normalContentHeight: CGFloat = 44 + static let expandedContentHeight: CGFloat = 48 + + static let leftBarButtonItemHorizontalInset: CGFloat = GlobalTokens.spacing(.size80) + static let rightBarButtonItemHorizontalInset: CGFloat = GlobalTokens.spacing(.size100) + + static let obscuringAnimationDuration: TimeInterval = 0.12 + static let revealingAnimationDuration: TimeInterval = 0.25 +} diff --git a/ios/FluentUI/Navigation/NavigationController.swift b/ios/FluentUI/Navigation/NavigationController.swift index f14926e80c..0438e2ef08 100644 --- a/ios/FluentUI/Navigation/NavigationController.swift +++ b/ios/FluentUI/Navigation/NavigationController.swift @@ -79,6 +79,8 @@ open class NavigationController: UINavigationController { super.delegate = self + msfNavigationBar.backButtonDelegate = self + // Allow subviews to display a custom background view view.subviews.forEach { $0.clipsToBounds = false } } @@ -126,13 +128,7 @@ open class NavigationController: UINavigationController { } private func viewControllerNeedsWrapping(_ viewController: UIViewController) -> Bool { - if viewController is ShyHeaderController { - return false - } - if viewController.navigationItem.usesLargeTitle || viewController.navigationItem.accessoryView != nil { - return true - } - return false + return !(viewController is ShyHeaderController) } func updateNavigationBar(for viewController: UIViewController) { @@ -142,6 +138,9 @@ open class NavigationController: UINavigationController { if let backgroundColor = msfNavigationBar.backgroundView.backgroundColor { transitionAnimator.tintColor = backgroundColor } + // ShyHeaderController sets its padding before the navigation item loads in, + // so we need to recalculate its padding now + (topViewController as? ShyHeaderController)?.updatePadding() } private func updateNavigationBarVisibility(for viewController: UIViewController, animated: Bool) { @@ -235,3 +234,11 @@ extension NavigationController: UINavigationControllerDelegate { return transitionAnimator } } + +// MARK: - NavigationController: NavigationBarBackButtonDelegate + +extension NavigationController: NavigationBarBackButtonDelegate { + func backButtonWasPressed() { + popViewController(animated: true) + } +} diff --git a/ios/FluentUI/Navigation/Shy Header/ShyHeaderController.swift b/ios/FluentUI/Navigation/Shy Header/ShyHeaderController.swift index e71149cf03..272fba2df9 100644 --- a/ios/FluentUI/Navigation/Shy Header/ShyHeaderController.swift +++ b/ios/FluentUI/Navigation/Shy Header/ShyHeaderController.swift @@ -142,7 +142,7 @@ class ShyHeaderController: UIViewController { paddingHeightConstraint = paddingHeight let paddingLeading = paddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor) - paddingHeight.identifier = "paddingView_leading" + paddingLeading.identifier = "paddingView_leading" constraints.append(paddingLeading) let paddingTrailing = paddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor) @@ -150,7 +150,7 @@ class ShyHeaderController: UIViewController { constraints.append(paddingTrailing) let paddingTop = paddingView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) - paddingTop.identifier = "shyView_top" + paddingTop.identifier = "paddingView_top" constraints.append(paddingTop) // ShyHeaderView @@ -254,8 +254,8 @@ class ShyHeaderController: UIViewController { } } - // if the originator is a LargeTitleView, make sure it belongs to this heirarchy - if let originatorTitleView = expansionRequestOriginator as? LargeTitleView { + // if the originator is an AvatarTitleView, make sure it belongs to this hierarchy + if let originatorTitleView = expansionRequestOriginator as? AvatarTitleView { guard originatorTitleView == msfNavigationController?.msfNavigationBar.titleView else { return false } @@ -275,7 +275,7 @@ class ShyHeaderController: UIViewController { } } - private func updatePadding() { + func updatePadding() { shyHeaderView.lockedInContractedState = msfNavigationController?.msfNavigationBar.barHeight == .contracted paddingHeightConstraint?.constant = paddingIsStatic ? paddingViewHeight : 0 shyViewHeightConstraint?.constant = shyHeaderView.maxHeight diff --git a/ios/FluentUI/Navigation/Shy Header/ShyHeaderView.swift b/ios/FluentUI/Navigation/Shy Header/ShyHeaderView.swift index 91401d4c72..0c6f50e7be 100644 --- a/ios/FluentUI/Navigation/Shy Header/ShyHeaderView.swift +++ b/ios/FluentUI/Navigation/Shy Header/ShyHeaderView.swift @@ -62,9 +62,9 @@ class ShyHeaderView: UIView, TokenizedControlInternal { static let contentBottomPadding: CGFloat = 10 static let contentBottomPaddingCompact: CGFloat = 6 static let accessoryHeight: CGFloat = 36 - static let maxHeightNoAccessory: CGFloat = 56 - 44 // navigation bar - design: 56, system: 44 - static let maxHeightNoAccessoryCompact: CGFloat = 44 - 32 // navigation bar - design: 44, system: 32 - static let maxHeightNoAccessoryCompactForLargePhone: CGFloat = 44 - 44 // navigation bar - design: 44, system: 44 + static let maxHeightNoAccessory: CGFloat = 56 - NavigationBarTokenSet.systemHeight // navigation bar - design: 56, system: 44 + static let maxHeightNoAccessoryCompact: CGFloat = 44 - NavigationBarTokenSet.compactSystemHeight // navigation bar - design: 44, system: 32 + static let maxHeightNoAccessoryCompactForLargePhone: CGFloat = 44 - NavigationBarTokenSet.systemHeight // navigation bar - design: 44, system: 44 } convenience init() { @@ -146,6 +146,10 @@ class ShyHeaderView: UIView, TokenizedControlInternal { if traitCollection.verticalSizeClass == .compact { return traitCollection.horizontalSizeClass == .compact ? Constants.maxHeightNoAccessoryCompact : Constants.maxHeightNoAccessoryCompactForLargePhone } + if traitCollection.horizontalSizeClass == .compact && parentController?.msfNavigationController?.msfNavigationBar.usesLeadingTitle == false { + // This is a portrait phone with a system-style title, the navigation bar is already 44px tall + return 0 + } return lockedInContractedState ? 0.0 : Constants.maxHeightNoAccessory } var maxHeightChanged: (() -> Void)? diff --git a/ios/FluentUI/Navigation/TwoLineTitleView+Navigation.swift b/ios/FluentUI/Navigation/TwoLineTitleView+Navigation.swift new file mode 100644 index 0000000000..2775b64964 --- /dev/null +++ b/ios/FluentUI/Navigation/TwoLineTitleView+Navigation.swift @@ -0,0 +1,65 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +// MARK: NavigationBarTitleAccessory enum extensions + +extension NavigationBarTitleAccessory: TwoLineTitleViewDelegate { + public func twoLineTitleViewDidTapOnTitle(_ twoLineTitleView: TwoLineTitleView) { + guard let delegate = delegate, + let navigationBar = twoLineTitleView.findSuperview(of: NavigationBar.self) as? NavigationBar else { + return + } + delegate.navigationBarDidTapOnTitle(navigationBar) + } +} + +fileprivate extension NavigationBarTitleAccessory.Location { + var twoLineTitleViewInteractivePart: TwoLineTitleView.InteractivePart { + switch self { + case .title: + return .title + case .subtitle: + return .subtitle + } + } +} + +fileprivate extension NavigationBarTitleAccessory.Style { + var twoLineTitleViewAccessoryType: TwoLineTitleView.AccessoryType { + switch self { + case .downArrow: + return .downArrow + case .disclosure: + return .disclosure + } + } +} + +extension TwoLineTitleView { + @objc open func setup(navigationItem: UINavigationItem) { + let title = navigationItem.title ?? "" + let alignment: Alignment = navigationItem.titleStyle == .system ? .center : .leading + + let interactivePart: InteractivePart + let accessoryType: AccessoryType + let animatesWhenPressed: Bool + if let titleAccessory = navigationItem.titleAccessory { + // Use the custom action provided by the title accessory specification + interactivePart = titleAccessory.location.twoLineTitleViewInteractivePart + accessoryType = titleAccessory.style.twoLineTitleViewAccessoryType + animatesWhenPressed = true + delegate = titleAccessory + } else { + // Use the default behavior of requesting expansion of the hosting navigation bar + interactivePart = .all + accessoryType = .none + animatesWhenPressed = false + } + + setup(title: title, titleImage: navigationItem.titleImage, subtitle: navigationItem.subtitle, alignment: alignment, interactivePart: interactivePart, animatesWhenPressed: animatesWhenPressed, accessoryType: accessoryType) + } +} diff --git a/ios/FluentUI/Navigation/UINavigationItem+Navigation.swift b/ios/FluentUI/Navigation/UINavigationItem+Navigation.swift index 5f8af88484..064d95b339 100644 --- a/ios/FluentUI/Navigation/UINavigationItem+Navigation.swift +++ b/ios/FluentUI/Navigation/UINavigationItem+Navigation.swift @@ -8,12 +8,15 @@ import UIKit @objc public extension UINavigationItem { private struct AssociatedKeys { static var accessoryView: String = "accessoryView" + static var titleAccessory: String = "titleAccessory" + static var titleImage: String = "titleImage" static var topAccessoryView: String = "topAccessoryView" static var topAccessoryViewAttributes: String = "topAccessoryViewAttributes" static var contentScrollView: String = "contentScrollView" static var navigationBarStyle: String = "navigationBarStyle" static var navigationBarShadow: String = "navigationBarShadow" - static var usesLargeTitle: String = "usesLargeTitle" + static var subtitle: String = "subtitle" + static var titleStyle: String = "titleStyle" static var customNavigationBarColor: String = "customNavigationBarColor" } @@ -26,6 +29,26 @@ import UIKit } } + /// 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 { + return objc_getAssociatedObject(self, &AssociatedKeys.titleAccessory) as? NavigationBarTitleAccessory + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.titleAccessory, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// An optional image to show in a navigation bar before the title. Ignored when `titleStyle == .largeLeading`. + var titleImage: UIImage? { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.titleImage) as? UIImage + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.titleImage, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + var topAccessoryView: UIView? { get { return objc_getAssociatedObject(self, &AssociatedKeys.topAccessoryView) as? UIView @@ -53,6 +76,7 @@ import UIKit } } + /// The style to apply to a navigation bar as a whole. Defaults to `.default` if not specified. var navigationBarStyle: NavigationBar.Style { get { return objc_getAssociatedObject(self, &AssociatedKeys.navigationBarStyle) as? NavigationBar.Style ?? .default @@ -71,17 +95,45 @@ import UIKit } } + /// The navigation item's subtitle that displays in the navigation bar. + var subtitle: String? { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.subtitle) as? String + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.subtitle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// The style in which the title text is displayed in a navigation bar. Defaults to `.system` if not specified. + var titleStyle: NavigationBar.TitleStyle { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.titleStyle) as? NavigationBar.TitleStyle ?? .system + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.titleStyle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + @available(*, deprecated, message: "Use `titleStyle` instead") var usesLargeTitle: Bool { get { - return objc_getAssociatedObject(self, &AssociatedKeys.usesLargeTitle) as? Bool ?? false + return titleStyle.usesLeadingAlignment } set { - objc_setAssociatedObject(self, &AssociatedKeys.usesLargeTitle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + titleStyle = newValue ? .largeLeading : .system } } func navigationBarColor(fluentTheme: FluentTheme) -> UIColor { - return navigationBarStyle.backgroundColor(fluentTheme: fluentTheme, customColor: customNavigationBarColor) + if let customNavigationBarColor = customNavigationBarColor, navigationBarStyle == .custom { + return customNavigationBarColor + } + + let style = navigationBarStyle + let tokenSet = NavigationBarTokenSet { style } + tokenSet.fluentTheme = fluentTheme + return tokenSet[.backgroundColor].uiColor } var customNavigationBarColor: UIColor? { diff --git a/ios/FluentUI/Navigation/Views/LargeTitleView.swift b/ios/FluentUI/Navigation/Views/AvatarTitleView.swift similarity index 74% rename from ios/FluentUI/Navigation/Views/LargeTitleView.swift rename to ios/FluentUI/Navigation/Views/AvatarTitleView.swift index 6b4f62b341..0e678dde56 100644 --- a/ios/FluentUI/Navigation/Views/LargeTitleView.swift +++ b/ios/FluentUI/Navigation/Views/AvatarTitleView.swift @@ -5,24 +5,19 @@ import UIKit -// MARK: LargeTitleView +// MARK: AvatarTitleView -/// Large Header and custom profile button container -class LargeTitleView: UIView { +/// A helper view used by `NavigationBar` capable of displaying a large title and an avatar. +class AvatarTitleView: UIView, TokenizedControlInternal, TwoLineTitleViewDelegate { enum Style: Int { case primary case system } - private struct Constants { - static let horizontalSpacing: CGFloat = 10 - - static let compactAvatarSize: MSFAvatarSize = .size24 - static let avatarSize: MSFAvatarSize = .size32 - - // Once we are iOS 14 minimum, we can use Fonts.largeTitle.withSize() function instead - static let compactTitleFont = UIFont.systemFont(ofSize: 26, weight: .bold) - } + typealias TokenSetKeyType = AvatarTitleViewTokenSet.Tokens + lazy var tokenSet: AvatarTitleViewTokenSet = .init(style: { [weak self] in + self?.style ?? .primary + }) var personaData: Persona? { didSet { @@ -51,9 +46,9 @@ class LargeTitleView: UIView { case .automatic: return case .contracted: - avatar?.state.size = Constants.compactAvatarSize + avatar?.state.size = TokenSetType.compactAvatarSize case .expanded: - avatar?.state.size = Constants.avatarSize + avatar?.state.size = TokenSetType.avatarSize } } } @@ -79,24 +74,12 @@ class LargeTitleView: UIView { var style: Style = .primary { didSet { - titleButton.setTitleColor(colorForStyle, for: .normal) + updateAppearance() + twoLineTitleView.currentStyle = style == .primary ? .primary : .system avatar?.state.style = style == .primary ? .default : .accent } } - var titleSize: NavigationBar.ElementSize = .automatic { - didSet { - switch titleSize { - case .automatic: - return - case .contracted: - titleButton.titleLabel?.font = Constants.compactTitleFont - case .expanded: - titleButton.titleLabel?.font = fluentTheme.typography(.title1) - } - } - } - var onAvatarTapped: (() -> Void)? { // called in response to a tap on the MSFAvatar's view didSet { updateAvatarViewPointerInteraction() @@ -112,19 +95,21 @@ class LargeTitleView: UIView { return avatar } - private var colorForStyle: UIColor { - switch style { - case .primary: - return UIColor(light: fluentTheme.color(.foregroundOnColor).light, - dark: fluentTheme.color(.foreground1).dark) - case .system: - return fluentTheme.color(.foreground1) + private var avatar: MSFAvatar? // circular view displaying the profile information + + private let titleContainerView = UIView() + + private var showsLargeTitle: Bool = false { + didSet { + if oldValue != showsLargeTitle { + updateTitleContainerView() + } } } - private var avatar: MSFAvatar? // circular view displaying the profile information - + // TODO: Once we have an iOS 15 minimum, we can use UIButton.Configuration to eliminate the need for TwoLineTitleView here private let titleButton = UIButton() // button used to display the title of the current navigation item + private let twoLineTitleView = TwoLineTitleView() // view used to display the title of the current navigation item if a subtitle exists private let contentStackView = UIStackView() // containing stack view @@ -159,18 +144,34 @@ class LargeTitleView: UIView { private func initBase() { setupLayout() setupAccessibility() + twoLineTitleView.delegate = self - NotificationCenter.default.addObserver(self, - selector: #selector(themeDidChange), - name: .didChangeTheme, - object: nil) + tokenSet.registerOnUpdate(for: self) { [weak self] in + self?.updateAppearance() + } } - @objc private func themeDidChange(_ notification: Notification) { - guard let themeView = notification.object as? UIView, self.isDescendant(of: themeView) else { + // MARK: - Theme updates + + open override func willMove(toWindow newWindow: UIWindow?) { + super.willMove(toWindow: newWindow) + guard let newWindow else { return } - titleButton.setTitleColor(colorForStyle, for: .normal) + tokenSet.update(newWindow.fluentTheme) + } + + @objc private func updateAppearance() { + titleButton.setTitleColor(tokenSet[.titleColor].uiColor, for: .normal) + titleButton.titleLabel?.font = tokenSet[.largeTitleFont].uiFont + + twoLineTitleView.currentStyle = style == .primary ? .primary : .system + twoLineTitleView.tokenSet.setOverrides(from: tokenSet, mapping: [ + .titleColor: .titleColor, + .titleFont: .titleFont, + .subtitleColor: .subtitleColor, + .subtitleFont: .subtitleFont + ]) } // MARK: - Base Construction Methods @@ -180,16 +181,16 @@ class LargeTitleView: UIView { // Also constructs gesture recognizers private func setupLayout() { // contentStackView layout - contentStackView.spacing = Constants.horizontalSpacing + contentStackView.spacing = TokenSetType.contentStackViewSpacing contentStackView.alignment = .center contain(view: contentStackView, withInsets: UIEdgeInsets(top: 0, - left: 8, + left: TokenSetType.contentStackViewHorizontalInset, bottom: 0, - right: 8)) + right: TokenSetType.contentStackViewHorizontalInset)) // Avatar setup let preferredFallbackImageStyle: MSFAvatarStyle = style == .primary ? .default : .accent let avatar = MSFAvatar(style: preferredFallbackImageStyle, - size: Constants.avatarSize) + size: TokenSetType.avatarSize) let avatarState = avatar.state avatarState.primaryText = personaData?.name avatarState.secondaryText = personaData?.email @@ -209,20 +210,22 @@ class LargeTitleView: UIView { avatarView.centerYAnchor.constraint(equalTo: contentStackView.centerYAnchor).isActive = true + updateTitleContainerView() + contentStackView.addArrangedSubview(titleContainerView) + // title button setup - contentStackView.addArrangedSubview(titleButton) titleButton.setTitle(nil, for: .normal) - titleButton.titleLabel?.font = fluentTheme.typography(.title1) - titleButton.setTitleColor(colorForStyle, for: .normal) titleButton.titleLabel?.textAlignment = .left titleButton.contentHorizontalAlignment = .left titleButton.titleLabel?.adjustsFontSizeToFitWidth = true - titleButton.addTarget(self, action: #selector(LargeTitleView.titleButtonTapped(sender:)), for: .touchUpInside) + titleButton.addTarget(self, action: #selector(AvatarTitleView.titleButtonTapped(sender:)), for: .touchUpInside) titleButton.setContentCompressionResistancePriority(.required, for: .horizontal) + updateAppearance() + // tap gesture for entire titleView - tapGesture.addTarget(self, action: #selector(LargeTitleView.handleTitleViewTapped(sender:))) + tapGesture.addTarget(self, action: #selector(AvatarTitleView.handleTitleViewTapped(sender:))) addGestureRecognizer(tapGesture) titleButton.showsLargeContentViewer = true @@ -231,24 +234,16 @@ class LargeTitleView: UIView { } private func expansionAnimation() { - if titleSize == .automatic { - titleButton.titleLabel?.font = fluentTheme.typography(.title1) - } - if avatarSize == .automatic { - avatar?.state.size = Constants.avatarSize + avatar?.state.size = TokenSetType.avatarSize } layoutIfNeeded() } private func contractionAnimation() { - if titleSize == .automatic { - titleButton.titleLabel?.font = Constants.compactTitleFont - } - if avatarSize == .automatic { - avatar?.state.size = Constants.compactAvatarSize + avatar?.state.size = TokenSetType.compactAvatarSize } layoutIfNeeded() @@ -311,12 +306,23 @@ class LargeTitleView: UIView { showsProfileButton = !hasLeftBarButtonItems && (personaData != nil || avatarOverrideStyle != nil) } + private func updateTitleContainerView() { + titleContainerView.removeAllSubviews() + titleContainerView.contain(view: showsLargeTitle ? titleButton : twoLineTitleView) + } + /// Sets the interface with the provided item's details /// /// - Parameter navigationItem: instance of UINavigationItem providing inteface information func update(with navigationItem: UINavigationItem) { hasLeftBarButtonItems = !(navigationItem.leftBarButtonItems?.isEmpty ?? true) titleButton.setTitle(navigationItem.title, for: .normal) + showsLargeTitle = navigationItem.titleStyle == .largeLeading + twoLineTitleView.setup(navigationItem: navigationItem) + if navigationItem.titleAccessory == nil { + // Use default behavior of requesting an accessory expansion + twoLineTitleView.delegate = self + } } // MARK: - Expansion/Contraction Methods @@ -326,7 +332,7 @@ class LargeTitleView: UIView { /// - Parameter animated: to animate the block or not func expand(animated: Bool) { // Exit early if neither element's size is automatic - guard titleSize == .automatic || avatarSize == .automatic else { + guard avatarSize == .automatic else { return } @@ -343,7 +349,7 @@ class LargeTitleView: UIView { /// - Parameter animated: to animate the block or not func contract(animated: Bool) { // Exit early if neither element's size is automatic - guard titleSize == .automatic || avatarSize == .automatic else { + guard avatarSize == .automatic else { return } if animated { @@ -367,6 +373,15 @@ class LargeTitleView: UIView { return !arrangedSubview.isHidden }) } + + // MARK: - TwoLineTitleViewDelegate + + func twoLineTitleViewDidTapOnTitle(_ twoLineTitleView: TwoLineTitleView) { + guard respondsToTaps, twoLineTitleView == self.twoLineTitleView else { + return + } + requestExpansion() + } } // MARK: - Notification.Name Declarations diff --git a/ios/FluentUI/Navigation/Views/AvatarTitleViewTokenSet.swift b/ios/FluentUI/Navigation/Views/AvatarTitleViewTokenSet.swift new file mode 100644 index 0000000000..aa556ba983 --- /dev/null +++ b/ios/FluentUI/Navigation/Views/AvatarTitleViewTokenSet.swift @@ -0,0 +1,73 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +/// Design token set for the `AvatarTitleView` control. +class AvatarTitleViewTokenSet: ControlTokenSet { + enum Tokens: TokenSetKey { + /// Describes the font used for a large one-line title. + case largeTitleFont + + /// Describes the color of the subtitle. + case subtitleColor + + /// Describes the font used for the subtitle. + case subtitleFont + + /// Describes the color of the title. + case titleColor + + /// Describes the font used for the title. + case titleFont + } + + init(style: @escaping () -> AvatarTitleView.Style) { + super.init { [style] token, theme in + switch token { + case .largeTitleFont: + return .uiFont { + theme.typography(.title1, adjustsForContentSizeCategory: false) + } + case .subtitleColor: + return .uiColor { + switch style() { + case .primary: + return UIColor(light: theme.color(.foregroundOnColor).light, + dark: theme.color(.foreground2).dark) + case .system: + return theme.color(.foreground2) + } + } + case .subtitleFont: + return .uiFont { + theme.typography(.caption1) + } + case .titleColor: + return .uiColor { + switch style() { + case .primary: + return UIColor(light: theme.color(.foregroundOnColor).light, + dark: theme.color(.foreground1).dark) + case .system: + return theme.color(.foreground1) + } + } + case .titleFont: + return .uiFont { + theme.typography(.body1Strong) + } + } + } + } +} + +extension AvatarTitleViewTokenSet { + static let compactAvatarSize: MSFAvatarSize = .size24 + static let avatarSize: MSFAvatarSize = .size32 + + static let contentStackViewSpacing: CGFloat = GlobalTokens.spacing(.size100) + static let contentStackViewHorizontalInset: CGFloat = GlobalTokens.spacing(.size80) +} diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/Contents.json b/ios/FluentUI/Resources/FluentUI-ios.xcassets/Contents.json index da4a164c91..73c00596a7 100644 --- a/ios/FluentUI/Resources/FluentUI-ios.xcassets/Contents.json +++ b/ios/FluentUI/Resources/FluentUI-ios.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/back-24x24.imageset/Contents.json b/ios/FluentUI/Resources/FluentUI-ios.xcassets/back-24x24.imageset/Contents.json index 040c68ce33..123add6f58 100644 --- a/ios/FluentUI/Resources/FluentUI-ios.xcassets/back-24x24.imageset/Contents.json +++ b/ios/FluentUI/Resources/FluentUI-ios.xcassets/back-24x24.imageset/Contents.json @@ -1,15 +1,16 @@ { "images" : [ { + "filename" : "ic_ios_arrow_left_24_outlined.pdf", "idiom" : "universal", - "filename" : "ic_ios_arrow_left_24_outlined.pdf" + "language-direction" : "left-to-right" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-12x12.imageset/Contents.json b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-12x12.imageset/Contents.json new file mode 100644 index 0000000000..20430b1625 --- /dev/null +++ b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-12x12.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_chevron_down_12_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-12x12.imageset/ic_fluent_chevron_down_12_filled.pdf b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-12x12.imageset/ic_fluent_chevron_down_12_filled.pdf new file mode 100644 index 0000000000..ba42c5951f Binary files /dev/null and b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-12x12.imageset/ic_fluent_chevron_down_12_filled.pdf differ diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-16x16.imageset/Contents.json b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-16x16.imageset/Contents.json new file mode 100644 index 0000000000..e433687ed0 --- /dev/null +++ b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-16x16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ic_fluent_chevron_down_16_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-16x16.imageset/ic_fluent_chevron_down_16_filled.pdf b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-16x16.imageset/ic_fluent_chevron_down_16_filled.pdf new file mode 100644 index 0000000000..d7abfed3bd Binary files /dev/null and b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-16x16.imageset/ic_fluent_chevron_down_16_filled.pdf differ diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-20x20.imageset/ic_chevron_down_20_outlined.pdf b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-20x20.imageset/ic_chevron_down_20_outlined.pdf deleted file mode 100644 index c0476c1410..0000000000 Binary files a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-20x20.imageset/ic_chevron_down_20_outlined.pdf and /dev/null differ diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-20x20.imageset/Contents.json b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-12x12.imageset/Contents.json similarity index 65% rename from ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-20x20.imageset/Contents.json rename to ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-12x12.imageset/Contents.json index 782a4c3a1f..3006ec1f8b 100644 --- a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-20x20.imageset/Contents.json +++ b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-12x12.imageset/Contents.json @@ -1,16 +1,16 @@ { "images" : [ { + "filename" : "ic_fluent_chevron_right_12_filled.pdf", "idiom" : "universal", - "filename" : "ic_chevron_right_20_outlined.pdf", "language-direction" : "left-to-right" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-12x12.imageset/ic_fluent_chevron_right_12_filled.pdf b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-12x12.imageset/ic_fluent_chevron_right_12_filled.pdf new file mode 100644 index 0000000000..4b41473568 Binary files /dev/null and b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-12x12.imageset/ic_fluent_chevron_right_12_filled.pdf differ diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-20x20.imageset/Contents.json b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-16x16.imageset/Contents.json similarity index 50% rename from ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-20x20.imageset/Contents.json rename to ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-16x16.imageset/Contents.json index cb45717e74..3e4ad4d3aa 100644 --- a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-down-20x20.imageset/Contents.json +++ b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-16x16.imageset/Contents.json @@ -1,15 +1,16 @@ { "images" : [ { + "filename" : "ic_fluent_chevron_right_16_filled.pdf", "idiom" : "universal", - "filename" : "ic_chevron_down_20_outlined.pdf" + "language-direction" : "left-to-right" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-16x16.imageset/ic_fluent_chevron_right_16_filled.pdf b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-16x16.imageset/ic_fluent_chevron_right_16_filled.pdf new file mode 100644 index 0000000000..4a5dcb0533 Binary files /dev/null and b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-16x16.imageset/ic_fluent_chevron_right_16_filled.pdf differ diff --git a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-20x20.imageset/ic_chevron_right_20_outlined.pdf b/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-20x20.imageset/ic_chevron_right_20_outlined.pdf deleted file mode 100644 index 7dd588c759..0000000000 Binary files a/ios/FluentUI/Resources/FluentUI-ios.xcassets/chevron-right-20x20.imageset/ic_chevron_right_20_outlined.pdf and /dev/null differ diff --git a/ios/FluentUI/TwoLineTitleView/TwoLineTitleView.resources.xcfilelist b/ios/FluentUI/TwoLineTitleView/TwoLineTitleView.resources.xcfilelist index 808e1666d3..e409057422 100644 --- a/ios/FluentUI/TwoLineTitleView/TwoLineTitleView.resources.xcfilelist +++ b/ios/FluentUI/TwoLineTitleView/TwoLineTitleView.resources.xcfilelist @@ -1,2 +1,4 @@ -chevron-down-20x20.imageset -chevron-right-20x20.imageset +chevron-down-12x12.imageset +chevron-down-16x16.imageset +chevron-right-12x12.imageset +chevron-right-16x16.imageset diff --git a/ios/FluentUI/TwoLineTitleView/TwoLineTitleView.swift b/ios/FluentUI/TwoLineTitleView/TwoLineTitleView.swift index a2948b4363..291350f771 100644 --- a/ios/FluentUI/TwoLineTitleView/TwoLineTitleView.swift +++ b/ios/FluentUI/TwoLineTitleView/TwoLineTitleView.swift @@ -7,34 +7,58 @@ import UIKit // MARK: TwoLineTitleViewDelegate +/// Handles user interactions with a `TwoLineTitleView`. @objc(MSFTwoLineTitleViewDelegate) public protocol TwoLineTitleViewDelegate: AnyObject { + /// Tells the delegate that a particular `TwoLineTitleView` was tapped. func twoLineTitleViewDidTapOnTitle(_ twoLineTitleView: TwoLineTitleView) } // MARK: - TwoLineTitleView @objc(MSFTwoLineTitleView) -open class TwoLineTitleView: UIView { - private struct Constants { - static let titleButtonLabelMarginBottomRegular: CGFloat = 0 - static let titleButtonLabelMarginBottomCompact: CGFloat = -2 - static let colorAnimationDuration: TimeInterval = 0.2 - static let colorAlpha: CGFloat = 1.0 - static let colorHighlightedAlpha: CGFloat = 0.4 - } - +open class TwoLineTitleView: UIView, TokenizedControlInternal { @objc(MSFTwoLineTitleViewStyle) public enum Style: Int { case primary case system } + @objc(MSFTwoLineTitleViewAlignment) + public enum Alignment: Int { + case center + case leading + + var stackViewAlignment: UIStackView.Alignment { + switch self { + case .center: + return .center + case .leading: + return .leading + } + } + + var xAxisKeyPath: KeyPath { + switch self { + case .center: + return \.centerXAnchor + case .leading: + return \.leadingAnchor + } + } + } + @objc(MSFTwoLineTitleViewInteractivePart) public enum InteractivePart: Int { - case none - case title - case subtitle + // The @objc requirement doesn't let us use OptionSet, so we provide the bitmasks and the `contains` method ourselves + case none = 0 + case title = 0b01 + case subtitle = 0b10 + case all = 0b11 + + func contains(_ other: InteractivePart) -> Bool { + return rawValue & other.rawValue != 0 + } } @objc(MSFTwoLineTitleViewAccessoryType) @@ -43,92 +67,102 @@ open class TwoLineTitleView: UIView { case disclosure case downArrow - var image: UIImage? { - let image: UIImage? - switch self { - case .disclosure: - image = UIImage.staticImageNamed("chevron-right-20x20") - case .downArrow: - image = UIImage.staticImageNamed("chevron-down-20x20") - case .none: - image = nil - } - return image - } - - var size: CGSize { return image?.size ?? .zero } - - var horizontalPadding: CGFloat { + public func image(isTitle: Bool) -> UIImage? { switch self { case .disclosure: - return 0 + return UIImage.staticImageNamed(isTitle ? "chevron-right-16x16" : "chevron-right-12x12") case .downArrow: - return -1 + return UIImage.staticImageNamed(isTitle ? "chevron-down-16x16" : "chevron-down-12x12") case .none: - return 0 + return nil } } - - var areaWidth: CGFloat { - return (size.width + horizontalPadding) * 2 - } } @objc open var titleAccessibilityHint: String? { - get { return titleButton.accessibilityHint } - set { titleButton.accessibilityHint = newValue } + get { return titleLabel.accessibilityHint } + set { titleLabel.accessibilityHint = newValue } } @objc open var titleAccessibilityTraits: UIAccessibilityTraits { - get { return titleButton.accessibilityTraits } - set { titleButton.accessibilityTraits = newValue } + get { return titleLabel.accessibilityTraits } + set { titleLabel.accessibilityTraits = newValue } } @objc open var subtitleAccessibilityHint: String? { - get { return subtitleButton.accessibilityHint } - set { subtitleButton.accessibilityHint = newValue } + get { return subtitleLabel.accessibilityHint } + set { subtitleLabel.accessibilityHint = newValue } } @objc open var subtitleAccessibilityTraits: UIAccessibilityTraits { - get { return subtitleButton.accessibilityTraits } - set { subtitleButton.accessibilityTraits = newValue } + get { return subtitleLabel.accessibilityTraits } + set { subtitleLabel.accessibilityTraits = newValue } } + public typealias TokenSetKeyType = TwoLineTitleViewTokenSet.Tokens + public lazy var tokenSet: TwoLineTitleViewTokenSet = .init(style: { [weak self] in + self?.currentStyle ?? .system + }) + @objc public weak var delegate: TwoLineTitleViewDelegate? + var currentStyle: Style { + didSet { + applyStyle() + } + } + + private lazy var alignmentConstraint: NSLayoutConstraint = centerXAnchor.constraint(equalTo: containingStackView.centerXAnchor) + private var alignment: Alignment = .center { + didSet { + guard alignment != oldValue else { + return + } + alignmentConstraint.isActive = false + let keyPath = alignment.xAxisKeyPath + alignmentConstraint = self[keyPath: keyPath].constraint(equalTo: containingStackView[keyPath: keyPath]) + alignmentConstraint.isActive = true + } + } private var interactivePart: InteractivePart = .none + private var animatesWhenPressed: Bool = true private var accessoryType: AccessoryType = .none - private let titleButton = EasyTapButton() - private var titleAccessoryType: AccessoryType { - return interactivePart == .title ? accessoryType : .none - } + // View hierarchy: + // containingStackView + // |--titleContainer + // | |--titleLeadingImageView (user-defined, optional) + // | |--titleLabel + // | |--titleTrailingImageView (chevron, optional) + // |--subtitleContainer + // | |--subtitleLabel + // | |--subtitleImageView (chevron, optional) + + private lazy var containingStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 0 + return stackView + }() - private var currentStyle: Style + private let titleContainer: UIStackView + private let subtitleContainer: UIStackView - private lazy var titleButtonLabel: Label = { - let label = Label() + private lazy var titleLabel: Label = { + let label = Label(textStyle: TokenSetType.defaultTitleFont, colorForTheme: { _ in self.tokenSet[.titleColor].uiColor }) label.lineBreakMode = .byTruncatingTail - label.style = .body1Strong - label.maxFontSize = 17 label.textAlignment = .center return label }() - private var titleButtonImageView = UIImageView() + private var titleLeadingImageView = UIImageView() + private var titleTrailingImageView = UIImageView() - private let subtitleButton = EasyTapButton() - private var subtitleAccessoryType: AccessoryType { - return interactivePart == .subtitle ? accessoryType : .none - } - - private lazy var subtitleButtonLabel: Label = { - let label = Label() + private lazy var subtitleLabel: Label = { + let label = Label(textStyle: TokenSetType.defaultSubtitleFont, colorForTheme: { _ in self.tokenSet[.subtitleColor].uiColor }) label.lineBreakMode = .byTruncatingMiddle - label.style = .caption1 - label.maxFontSize = 12 return label }() - private var subtitleButtonImageView = UIImageView() + private var subtitleImageView = UIImageView() @objc public convenience init(style: Style = .primary) { self.init(frame: .zero) @@ -140,52 +174,57 @@ open class TwoLineTitleView: UIView { public override init(frame: CGRect) { self.currentStyle = .system + titleContainer = UIStackView() + subtitleContainer = UIStackView() + super.init(frame: frame) + tokenSet.registerOnUpdate(for: self) { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.applyStyle() + strongSelf.updateFonts() + } + applyStyle() - titleButton.addTarget(self, action: #selector(onTitleButtonHighlighted), for: [.touchDown, .touchDragInside, .touchDragEnter]) - titleButton.addTarget(self, action: #selector(onTitleButtonUnhighlighted), for: [.touchUpInside, .touchDragOutside, .touchDragExit]) - titleButton.addTarget(self, action: #selector(onTitleButtonTapped), for: [.touchUpInside]) - addSubview(titleButton) + titleContainer.axis = .horizontal + titleContainer.spacing = TokenSetType.titleStackSpacing + subtitleContainer.axis = .horizontal + subtitleContainer.spacing = TokenSetType.titleStackSpacing - titleButton.addSubview(titleButtonLabel) - titleButton.addSubview(titleButtonImageView) + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTitleTapped))) - subtitleButton.addTarget(self, action: #selector(onSubtitleButtonHighlighted), for: [.touchDown, .touchDragInside, .touchDragEnter]) - subtitleButton.addTarget(self, action: #selector(onSubtitleButtonUnhighlighted), for: [.touchUpInside, .touchDragOutside, .touchDragExit]) - subtitleButton.addTarget(self, action: #selector(onTitleButtonTapped), for: [.touchUpInside]) - addSubview(subtitleButton) + // We do all of this instead of a simple contain(view:) to account for the minimum touch size + addSubview(containingStackView) + containingStackView.translatesAutoresizingMaskIntoConstraints = false - subtitleButton.addSubview(subtitleButtonLabel) - subtitleButton.addSubview(subtitleButtonImageView) + NSLayoutConstraint.activate([ + // Ensure minimum touch size + widthAnchor.constraint(greaterThanOrEqualToConstant: TokenSetType.minimumTouchSize.width), + heightAnchor.constraint(greaterThanOrEqualToConstant: TokenSetType.minimumTouchSize.height), + // Contain and center containingStackView within ourself + centerXAnchor.constraint(equalTo: containingStackView.centerXAnchor), + centerYAnchor.constraint(equalTo: containingStackView.centerYAnchor), + widthAnchor.constraint(greaterThanOrEqualTo: containingStackView.widthAnchor), + heightAnchor.constraint(greaterThanOrEqualTo: containingStackView.heightAnchor) + ]) - setupTitleButtonColor(highlighted: false, animated: false) - setupSubtitleButtonColor(highlighted: false, animated: false) + // Initial setup of subviews + setupTitleColor(highlighted: false, animated: false) + setupSubtitleColor(highlighted: false, animated: false) - titleButtonImageView.contentMode = .scaleAspectFit - subtitleButtonImageView.contentMode = .scaleAspectFit + titleLeadingImageView.contentMode = .scaleAspectFit + titleTrailingImageView.contentMode = .scaleAspectFit + subtitleImageView.contentMode = .scaleAspectFit - titleButton.accessibilityTraits = [.staticText, .header] - subtitleButton.accessibilityTraits = [.staticText, .header] + titleLabel.accessibilityTraits = [.staticText, .header] + subtitleLabel.accessibilityTraits = [.staticText, .header] addInteraction(UILargeContentViewerInteraction()) - titleButtonLabel.showsLargeContentViewer = true - subtitleButtonLabel.showsLargeContentViewer = true - - updateFonts() - - NotificationCenter.default.addObserver(self, - selector: #selector(themeDidChange), - name: .didChangeTheme, - object: nil) - } - - @objc private func themeDidChange(_ notification: Notification) { - guard let themeView = notification.object as? UIView, self.isDescendant(of: themeView) else { - return - } - applyStyle() + titleLabel.showsLargeContentViewer = true + subtitleLabel.showsLargeContentViewer = true } public required init?(coder aDecoder: NSCoder) { @@ -202,168 +241,174 @@ open class TwoLineTitleView: UIView { /// - interactivePart: Determines which line, if any, of the view will have interactive button behavior. /// - accessoryType: Determines which accessory will be shown with the `interactivePart` of the view, if any. Ignored if `interactivePart` is `.none`. @objc open func setup(title: String, subtitle: String? = nil, interactivePart: InteractivePart = .none, accessoryType: AccessoryType = .none) { + setup(title: title, subtitle: subtitle, alignment: .center, interactivePart: interactivePart, animatesWhenPressed: true, accessoryType: accessoryType) + } + + /// Sets the relevant strings and button styles for the title and subtitle. + /// + /// - Parameters: + /// - title: A title string. + /// - titleImage: An optional image to display before the title. + /// - subtitle: An optional subtitle string. If nil, title will take up entire frame. + /// - alignment: How to align the title and subtitle. Ignored if `subtitle` is nil. + /// - interactivePart: Determines which line, if any, of the view will have interactive button behavior. + /// - animatesWhenPressed: If true, the text color will flash when pressed. Ignored if `interactivePart` is `.none`. + /// - accessoryType: Determines which accessory will be shown with the `interactivePart` of the view, if any. Ignored if `interactivePart` is `.none`. + @objc open func setup(title: String, titleImage: UIImage? = nil, subtitle: String? = nil, alignment: Alignment = .center, interactivePart: InteractivePart = .none, animatesWhenPressed: Bool = true, accessoryType: AccessoryType = .none) { + self.alignment = alignment self.interactivePart = interactivePart + self.animatesWhenPressed = animatesWhenPressed self.accessoryType = accessoryType - setupButton(titleButton, label: titleButtonLabel, imageView: titleButtonImageView, text: title, interactive: interactivePart == .title, accessoryType: accessoryType) - setupButton(subtitleButton, label: subtitleButtonLabel, imageView: subtitleButtonImageView, text: subtitle, interactive: interactivePart == .subtitle, accessoryType: accessoryType) + titleLeadingImageView.image = titleImage + titleLeadingImageView.isHidden = titleImage == nil + + setupTitleLine(titleContainer, label: titleLabel, trailingImageView: titleTrailingImageView, text: title, interactive: interactivePart.contains(.title), accessoryType: accessoryType) + if titleLeadingImageView.image != nil { + titleContainer.insertArrangedSubview(titleLeadingImageView, at: 0) + } + + // Check for strict equality for the subtitle button's interactivity. + // If the whole area is active, we'll use the title as the main accessibility item. + setupTitleLine(subtitleContainer, label: subtitleLabel, trailingImageView: subtitleImageView, text: subtitle, interactive: interactivePart == .subtitle, accessoryType: accessoryType) - setNeedsLayout() + minimumContentSizeCategory = .large + + containingStackView.removeAllSubviews() + containingStackView.alignment = alignment.stackViewAlignment + containingStackView.addArrangedSubview(titleContainer) + + if subtitle?.isEmpty == false { + maximumContentSizeCategory = .large + containingStackView.addArrangedSubview(subtitleContainer) + } else { + maximumContentSizeCategory = .extraExtraLarge + } } // MARK: Highlighting private func applyStyle() { - switch currentStyle { - case .system: - titleButtonLabel.textColor = fluentTheme.color(.foreground1) - subtitleButtonLabel.textColor = fluentTheme.color(.foreground2) - titleButtonImageView.tintColor = fluentTheme.color(.foreground2) - case .primary: - titleButtonLabel.textColor = UIColor(light: fluentTheme.color(.foregroundOnColor).light, - dark: fluentTheme.color(.foreground1).dark) - subtitleButtonLabel.textColor = UIColor(light: fluentTheme.color(.foregroundOnColor).light, - dark: fluentTheme.color(.foreground2).dark) - titleButtonImageView.tintColor = UIColor(light: fluentTheme.color(.foregroundOnColor).light, - dark: fluentTheme.color(.foreground2).dark) - } + titleLabel.tokenSet.setOverrides(from: tokenSet, mapping: [.textColor: .titleColor]) + let titleColor = titleLabel.tokenSet[.textColor].uiColor + titleLeadingImageView.tintColor = titleColor + titleTrailingImageView.tintColor = titleColor - // unlike title accessory image view, subtitle accessory image view should be the same color as subtitle label - subtitleButtonImageView.tintColor = subtitleButtonLabel.textColor + subtitleLabel.tokenSet.setOverrides(from: tokenSet, mapping: [.textColor: .subtitleColor]) + subtitleImageView.tintColor = subtitleLabel.tokenSet[.textColor].uiColor } - private func setupTitleButtonColor(highlighted: Bool, animated: Bool) { - setupColor(highlighted: highlighted, animated: animated, onLabel: titleButtonLabel, onImageView: titleButtonImageView) + private func setupTitleColor(highlighted: Bool, animated: Bool) { + setupColor(highlighted: highlighted, animated: animated, onLabel: titleLabel, onImageViews: [titleLeadingImageView, titleTrailingImageView]) } - private func setupSubtitleButtonColor(highlighted: Bool, animated: Bool) { - setupColor(highlighted: highlighted, animated: animated, onLabel: subtitleButtonLabel, onImageView: subtitleButtonImageView) + private func setupSubtitleColor(highlighted: Bool, animated: Bool) { + setupColor(highlighted: highlighted, animated: animated, onLabel: subtitleLabel, onImageView: subtitleImageView) } private func setupColor(highlighted: Bool, animated: Bool, onLabel label: UILabel, onImageView imageView: UIImageView) { + setupColor(highlighted: highlighted, animated: animated, onLabel: label, onImageViews: [imageView]) + } + + private func setupColor(highlighted: Bool, animated: Bool, onLabel label: UILabel, onImageViews imageViews: [UIImageView]) { // Highlighting is never animated to match iOS - let duration = !highlighted && animated ? Constants.colorAnimationDuration : 0 + let duration = !highlighted && animated ? TokenSetType.textColorAnimationDuration : 0 UIView.animate(withDuration: duration) { + let alpha = TokenSetType.textColorAlpha(highlighted: highlighted) + // Button label - label.alpha = (highlighted) ? Constants.colorHighlightedAlpha : Constants.colorAlpha + label.alpha = alpha - // Button image view - imageView.alpha = (highlighted) ? Constants.colorHighlightedAlpha : Constants.colorAlpha + // Button image views + imageViews.forEach { + $0.alpha = alpha + } } } - private func setupButton(_ button: UIButton, label: UILabel, imageView: UIImageView, text: String?, interactive: Bool, accessoryType: AccessoryType) { - button.isUserInteractionEnabled = interactive - button.accessibilityLabel = text + private func setupTitleLine(_ container: UIStackView, label: UILabel, trailingImageView: UIImageView, text: String?, interactive: Bool, accessoryType: AccessoryType) { + container.accessibilityLabel = text + label.text = text + + container.removeAllSubviews() + container.addArrangedSubview(label) + if interactive { - button.accessibilityTraits.insert(.button) - button.accessibilityTraits.remove(.staticText) + container.accessibilityTraits.insert(.button) + container.accessibilityTraits.remove(.staticText) + trailingImageView.image = accessoryType.image(isTitle: container == titleContainer) } else { - button.accessibilityTraits.insert(.staticText) - button.accessibilityTraits.remove(.button) + container.accessibilityTraits.insert(.staticText) + container.accessibilityTraits.remove(.button) + trailingImageView.image = nil } - label.text = text - imageView.image = accessoryType.image - imageView.isHidden = imageView.image == nil + trailingImageView.isHidden = trailingImageView.image == nil + if trailingImageView.image != nil { + container.addArrangedSubview(trailingImageView) + } } // MARK: Layout - open override func sizeThatFits(_ size: CGSize) -> CGSize { - var titleSize = titleButtonLabel.sizeThatFits(size) - titleSize.width += titleAccessoryType.areaWidth - - var subtitleSize = subtitleButtonLabel.sizeThatFits(size) - subtitleSize.width += subtitleAccessoryType.areaWidth - - return CGSize(width: max(titleSize.width, subtitleSize.width), height: titleSize.height + subtitleSize.height) + private func updateFonts() { + titleLabel.tokenSet.setOverrides(from: tokenSet, mapping: [.font: .titleFont]) + subtitleLabel.tokenSet.setOverrides(from: tokenSet, mapping: [.font: .subtitleFont]) } - private func updateFonts() { - titleButtonLabel.font = fluentTheme.typography(.body1Strong) - subtitleButtonLabel.font = fluentTheme.typography(.caption1) - } - - open override func layoutSubviews() { - super.layoutSubviews() - - let isCompact = traitCollection.verticalSizeClass == .compact - - let titleButtonHeight = titleButtonLabel.font.lineHeight - let titleBottomMargin = isCompact ? Constants.titleButtonLabelMarginBottomCompact : Constants.titleButtonLabelMarginBottomRegular - let subtitleButtonHeight = subtitleButtonLabel.font.lineHeight - let totalContentHeight = titleButtonHeight + titleBottomMargin + subtitleButtonHeight - var top = ceil((bounds.height - totalContentHeight) / 2.0) - - titleButton.frame = CGRect(x: 0, y: top, width: bounds.width, height: titleButtonHeight).integral - top += titleButtonHeight + titleBottomMargin - - let titleButtonLabelMaxWidth = titleButton.bounds.width - titleAccessoryType.areaWidth - titleButtonLabel.sizeToFit() - let titleButtonLabelWidth = min(titleButtonLabelMaxWidth, titleButtonLabel.frame.width) - titleButtonLabel.frame = CGRect( - x: ceil((titleButton.frame.width - titleButtonLabelWidth) / 2.0), - y: 0, - width: titleButtonLabelWidth, - height: titleButton.frame.height - ) - - titleButtonImageView.frame = CGRect( - origin: CGPoint(x: titleButtonLabel.frame.maxX + titleAccessoryType.horizontalPadding, y: 0), - size: titleAccessoryType.size - ) - - titleButtonImageView.centerInSuperview(horizontally: false, vertically: true) - - if subtitleButtonLabel.text != nil { - subtitleButton.frame = CGRect(x: frame.origin.x, y: top, width: bounds.width, height: subtitleButtonHeight).integral - - let subtitleButtonLabelMaxWidth = interactivePart == .subtitle ? subtitleButton.bounds.width - subtitleAccessoryType.areaWidth : titleButton.bounds.width - subtitleButtonLabel.sizeToFit() - let subtitleButtonLabelWidth = min(subtitleButtonLabelMaxWidth, subtitleButtonLabel.frame.width) - subtitleButtonLabel.frame = CGRect( - x: ceil((subtitleButton.frame.width - subtitleButtonLabelWidth) / 2.0), - y: 0, - width: subtitleButtonLabelWidth, - height: subtitleButton.frame.height - ) - subtitleButtonImageView.frame = CGRect( - x: subtitleButtonLabel.frame.maxX + subtitleAccessoryType.horizontalPadding, - y: ceil((subtitleButton.frame.height - subtitleAccessoryType.size.height) / 2.0), - width: subtitleAccessoryType.size.width, - height: subtitleAccessoryType.size.height - ) - } else { - // The view is configured as a single line (title) view only. - titleButton.centerInSuperview() + open override func willMove(toWindow newWindow: UIWindow?) { + super.willMove(toWindow: newWindow) + guard let newWindow else { + return } - - titleButton.flipSubviewsForRTL() - subtitleButton.flipSubviewsForRTL() + tokenSet.update(newWindow.fluentTheme) + applyStyle() + updateFonts() } // MARK: Actions - @objc private func onTitleButtonHighlighted() { - setupTitleButtonColor(highlighted: true, animated: true) + @objc private func onTitleTapped() { + delegate?.twoLineTitleViewDidTapOnTitle(self) } - @objc private func onTitleButtonUnhighlighted() { - setupTitleButtonColor(highlighted: false, animated: true) + open override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + guard animatesWhenPressed, touches.contains(where: { bounds.contains($0.location(in: self)) }) else { + return + } + setTitleHighlight(true) } - @objc private func onTitleButtonTapped() { - delegate?.twoLineTitleViewDidTapOnTitle(self) + open override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + guard animatesWhenPressed else { + return + } + setTitleHighlight(false) } - @objc private func onSubtitleButtonHighlighted() { - setupSubtitleButtonColor(highlighted: true, animated: true) + open override func touchesMoved(_ touches: Set, with event: UIEvent?) { + super.touchesMoved(touches, with: event) + guard animatesWhenPressed else { + return + } + setTitleHighlight(touches.allSatisfy { bounds.contains($0.location(in: self)) }) + } + + open override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + guard animatesWhenPressed else { + return + } + setTitleHighlight(false) } - @objc private func onSubtitleButtonUnhighlighted() { - setupSubtitleButtonColor(highlighted: false, animated: true) + private func setTitleHighlight(_ value: Bool) { + assert(animatesWhenPressed, "setTitleHighlight(_) should only be called when animatesWhenPressed is true") + setupTitleColor(highlighted: value && interactivePart.contains(.title), animated: true) + setupSubtitleColor(highlighted: value && interactivePart.contains(.subtitle), animated: true) } // MARK: Accessibility @@ -371,21 +416,21 @@ open class TwoLineTitleView: UIView { open override var isAccessibilityElement: Bool { get { return false } set { } } open override func accessibilityElementCount() -> Int { - return subtitleButtonLabel.text != nil ? 2 : 1 + return subtitleLabel.text != nil ? 2 : 1 } open override func accessibilityElement(at index: Int) -> Any? { if index == 0 { - return titleButton + return titleLabel } else if index == 1 { - return subtitleButton + return subtitleLabel } return nil } open override func index(ofAccessibilityElement element: Any) -> Int { if let view = element as? UIView { - return view == titleButton ? 0 : 1 + return view == titleLabel ? 0 : 1 } return -1 } diff --git a/ios/FluentUI/TwoLineTitleView/TwoLineTitleViewTokenSet.swift b/ios/FluentUI/TwoLineTitleView/TwoLineTitleViewTokenSet.swift new file mode 100644 index 0000000000..736e9bb7f2 --- /dev/null +++ b/ios/FluentUI/TwoLineTitleView/TwoLineTitleViewTokenSet.swift @@ -0,0 +1,76 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +/// Design token set for the `TwoLineTitleView` control. +public class TwoLineTitleViewTokenSet: ControlTokenSet { + public enum Tokens: TokenSetKey { + /// Describes the color of the subtitle. + case subtitleColor + + /// Describes the font used for the subtitle. + case subtitleFont + + /// Describes the color of the title. + case titleColor + + /// Describes the font used for the title. + case titleFont + } + + init(style: @escaping () -> TwoLineTitleView.Style) { + super.init { [style] token, theme in + switch token { + case .subtitleColor: + return .uiColor { + switch style() { + case .primary: + return UIColor(light: theme.color(.foregroundOnColor).light, + dark: theme.color(.foreground2).dark) + case .system: + return theme.color(.foreground2) + } + } + case .subtitleFont: + return .uiFont { + theme.typography(Self.defaultSubtitleFont) + } + case .titleColor: + return .uiColor { + switch style() { + case .primary: + return UIColor(light: theme.color(.foregroundOnColor).light, + dark: theme.color(.foreground1).dark) + case .system: + return theme.color(.foreground1) + } + } + case .titleFont: + return .uiFont { + theme.typography(Self.defaultTitleFont) + } + } + } + } +} + +extension TwoLineTitleViewTokenSet { + static let textColorAnimationDuration: TimeInterval = 0.2 + + static func textColorAlpha(highlighted: Bool) -> CGFloat { + highlighted ? 0.4 : 1 + } + + static let defaultTitleFont: FluentTheme.TypographyToken = .body1Strong + static let defaultSubtitleFont: FluentTheme.TypographyToken = .caption1 + + static let minimumTouchSize: CGSize = EasyTapButton.minimumTouchSize + + static let titleImageSizeToken: GlobalTokens.IconSizeToken = .size160 + static let subtitleImageSizeToken: GlobalTokens.IconSizeToken = .size120 + + static let titleStackSpacing = GlobalTokens.spacing(.size40) +} diff --git a/ios/docs/Controls/.attachments/Navigation-Accessory-Image-TitleDownArrow.png b/ios/docs/Controls/.attachments/Navigation-Accessory-Image-TitleDownArrow.png new file mode 100644 index 0000000000..3939c16aad Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-Accessory-Image-TitleDownArrow.png differ diff --git a/ios/docs/Controls/.attachments/Navigation-Accessory-SubtitleDisclosure.png b/ios/docs/Controls/.attachments/Navigation-Accessory-SubtitleDisclosure.png new file mode 100644 index 0000000000..721d0b3c5f Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-Accessory-SubtitleDisclosure.png differ diff --git a/ios/docs/Controls/.attachments/Navigation-Style-Custom.png b/ios/docs/Controls/.attachments/Navigation-Style-Custom.png new file mode 100644 index 0000000000..6d99379e3e Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-Style-Custom.png differ diff --git a/ios/docs/Controls/.attachments/Navigation-Style-Primary.png b/ios/docs/Controls/.attachments/Navigation-Style-Primary.png new file mode 100644 index 0000000000..76c51700b1 Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-Style-Primary.png differ diff --git a/ios/docs/Controls/.attachments/Navigation-Style-System.png b/ios/docs/Controls/.attachments/Navigation-Style-System.png new file mode 100644 index 0000000000..096ac7170f Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-Style-System.png differ diff --git a/ios/docs/Controls/.attachments/Navigation-TitleStyle-Leading1.png b/ios/docs/Controls/.attachments/Navigation-TitleStyle-Leading1.png new file mode 100644 index 0000000000..79735c175f Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-TitleStyle-Leading1.png differ diff --git a/ios/docs/Controls/.attachments/Navigation-TitleStyle-Leading2.png b/ios/docs/Controls/.attachments/Navigation-TitleStyle-Leading2.png new file mode 100644 index 0000000000..63dc630bec Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-TitleStyle-Leading2.png differ diff --git a/ios/docs/Controls/.attachments/Navigation-TitleStyle-System1.png b/ios/docs/Controls/.attachments/Navigation-TitleStyle-System1.png new file mode 100644 index 0000000000..01b807fa48 Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-TitleStyle-System1.png differ diff --git a/ios/docs/Controls/.attachments/Navigation-TitleStyle-System2.png b/ios/docs/Controls/.attachments/Navigation-TitleStyle-System2.png new file mode 100644 index 0000000000..f177f245fc Binary files /dev/null and b/ios/docs/Controls/.attachments/Navigation-TitleStyle-System2.png differ diff --git a/ios/docs/Controls/Navigation.md b/ios/docs/Controls/Navigation.md new file mode 100644 index 0000000000..52a87a96f0 --- /dev/null +++ b/ios/docs/Controls/Navigation.md @@ -0,0 +1,40 @@ +# Navigation + +## Overview + +Use a `NavigationController` to enable users to navigate through hierarchical data. `NavigationController`, along with [extensions to `UINavigationItem`](https://github.com/microsoft/fluentui-apple/blob/main/ios/FluentUI/Navigation/UINavigationItem%2BNavigation.swift), allow you to render all relevant information with a Fluent look and feel. + +### Appearance Examples + +| `NavigationBar.Style` | Example | +|-|-| +| `.primary` | ![Navigation-Style-Primary.png](.attachments/Navigation-Style-Primary.png) | +| `.system` | ![Navigation-Style-System.png](.attachments/Navigation-Style-System.png) | +| `.custom` | ![Navigation-Style-Custom.png](.attachments/Navigation-Style-Custom.png) | + +| `NavigationBar.TitleStyle` | Example | +|-|-| +| `.system` | ![Navigation-TitleStyle-System1.png](.attachments/Navigation-TitleStyle-System1.png) ![Navigation-TitleStyle-System2.png](.attachments/Navigation-TitleStyle-System2.png) | +| `.leading` | ![Navigation-TitleStyle-Leading1.png](.attachments/Navigation-TitleStyle-Leading1.png) ![Navigation-TitleStyle-Leading2.png](.attachments/Navigation-TitleStyle-Leading2.png) | +| `.largeLeading` | ![Navigation-Style-Primary.png](.attachments/Navigation-Style-Primary.png) | + +### More Customization Options + +By specifying an appropriate instance of `NavigationBarTitleAccessory`, you can indicate to users that the title can be pressed. + +You can also specify an optional `titleImage` with the associated navigation item. + +| Specifications | Example | +|-|-| +| Title down arrow with `titleImage` | ![Navigation-Accessory-Image-TitleDownArrow.png](.attachments/Navigation-Accessory-Image-TitleDownArrow.png) +| Subtitle disclosure | ![Navigation-Accessory-SubtitleDisclosure.png](.attachments/Navigation-Accessory-SubtitleDisclosure.png) + +## Implementation + +### Source Code + +[Navigation folder](https://github.com/microsoft/fluentui-apple/blob/main/ios/FluentUI/Navigation/) + +### Sample Code + +[NavigationControllerDemoController.swift](https://github.com/microsoft/fluentui-apple/blob/main/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift)