From 83cf151905941cca0cf9ab4f3990ada5e4045404 Mon Sep 17 00:00:00 2001 From: Lukas Capkovic <3610850+lcapkovic@users.noreply.github.com> Date: Tue, 18 May 2021 17:32:10 -0700 Subject: [PATCH] Initial versions of Bottom Sheet and Bottom Commanding (#574) * V1 of empty BottomSheetController (#534) * Bottom commanding controller (#552) * Various clean up of bottomSheet-dev before merge into main (#566) --- MicrosoftFluentUI.podspec | 17 + .../FluentUI.Demo.xcodeproj/project.pbxproj | 8 + ios/FluentUI.Demo/FluentUI.Demo/Demos.swift | 2 + .../BottomCommandingDemoController.swift | 255 ++++++++ .../Demos/BottomSheetDemoController.swift | 149 +++++ ios/FluentUI.xcodeproj/project.pbxproj | 46 ++ .../BottomCommanding.resources.xcfilelist | 1 + .../BottomCommandingController.swift | 589 ++++++++++++++++++ .../Bottom Commanding/CommandingItem.swift | 127 ++++ .../Bottom Commanding/CommandingSection.swift | 23 + .../Bottom Sheet/BottomSheetController.swift | 481 ++++++++++++++ .../BottomSheetPassthroughView.swift | 13 + .../Localization/en.lproj/Localizable.strings | 3 + ios/FluentUI/Tab Bar/TabBarItemView.swift | 8 + 14 files changed, 1722 insertions(+) create mode 100644 ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomCommandingDemoController.swift create mode 100644 ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomSheetDemoController.swift create mode 100644 ios/FluentUI/Bottom Commanding/BottomCommanding.resources.xcfilelist create mode 100644 ios/FluentUI/Bottom Commanding/BottomCommandingController.swift create mode 100644 ios/FluentUI/Bottom Commanding/CommandingItem.swift create mode 100644 ios/FluentUI/Bottom Commanding/CommandingSection.swift create mode 100644 ios/FluentUI/Bottom Sheet/BottomSheetController.swift create mode 100644 ios/FluentUI/Bottom Sheet/BottomSheetPassthroughView.swift diff --git a/MicrosoftFluentUI.podspec b/MicrosoftFluentUI.podspec index 10a92f65b..8d01356e2 100644 --- a/MicrosoftFluentUI.podspec +++ b/MicrosoftFluentUI.podspec @@ -46,6 +46,23 @@ Pod::Spec.new do |s| barbuttonitems_ios.source_files = ["ios/FluentUI/BarButtonItems/**/*.{swift,h}"] end + s.subspec 'BottomCommanding_ios' do |bottomcommanding_ios| + bottomcommanding_ios.platform = :ios + bottomcommanding_ios.dependency 'MicrosoftFluentUI/BottomSheet_ios' + bottomcommanding_ios.dependency 'MicrosoftFluentUI/OtherCells_ios' + bottomcommanding_ios.dependency 'MicrosoftFluentUI/Separator_ios' + bottomcommanding_ios.dependency 'MicrosoftFluentUI/TabBar_ios' + bottomcommanding_ios.dependency 'MicrosoftFluentUI/TableView_ios' + bottomcommanding_ios.preserve_paths = ["ios/FluentUI/Bottom Commanding/BottomCommanding.resources.xcfilelist"] + bottomcommanding_ios.source_files = ["ios/FluentUI/Bottom Commanding/**/*.{swift,h}"] + end + + s.subspec 'BottomSheet_ios' do |bottomsheet_ios| + bottomsheet_ios.platform = :ios + bottomsheet_ios.dependency 'MicrosoftFluentUI/ResizingHandleView_ios' + bottomsheet_ios.source_files = ["ios/FluentUI/Bottom Sheet/**/*.{swift,h}"] + end + s.subspec 'Button_ios' do |button_ios| button_ios.platform = :ios button_ios.dependency 'MicrosoftFluentUI/Core_ios' diff --git a/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj b/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj index b44d7dca7..ca421e7c5 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj +++ b/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 7D0931C124AAA3D30072458A /* SideTabBarDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0931C024AAA3D30072458A /* SideTabBarDemoController.swift */; }; 7D23482A24D89C1C00FBE057 /* AvatarGroupViewDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23482924D89C1C00FBE057 /* AvatarGroupViewDemoController.swift */; }; 7DC2FB2B24C0F4FD00367A55 /* TableViewCellFileAccessoryViewDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC2FB2A24C0F4FD00367A55 /* TableViewCellFileAccessoryViewDemoController.swift */; }; + 80AECC0C2630F1BB005AF2F3 /* BottomCommandingDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AECC0B2630F1BB005AF2F3 /* BottomCommandingDemoController.swift */; }; + 80B1F7012628D8BB004DFEE5 /* BottomSheetDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B1F7002628D8BB004DFEE5 /* BottomSheetDemoController.swift */; }; 8AF03E2024B6BE3100E6E2A2 /* ContactCollectionViewDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF03E1F24B6BE3100E6E2A2 /* ContactCollectionViewDemoController.swift */; }; A589F856211BA71000471C23 /* LabelDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A589F855211BA71000471C23 /* LabelDemoController.swift */; }; A591A3F420F429EB001ED23B /* Demos.swift in Sources */ = {isa = PBXBuildFile; fileRef = A591A3F320F429EB001ED23B /* Demos.swift */; }; @@ -83,6 +85,8 @@ 7D0931C024AAA3D30072458A /* SideTabBarDemoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SideTabBarDemoController.swift; sourceTree = ""; }; 7D23482924D89C1C00FBE057 /* AvatarGroupViewDemoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarGroupViewDemoController.swift; sourceTree = ""; }; 7DC2FB2A24C0F4FD00367A55 /* TableViewCellFileAccessoryViewDemoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableViewCellFileAccessoryViewDemoController.swift; sourceTree = ""; }; + 80AECC0B2630F1BB005AF2F3 /* BottomCommandingDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomCommandingDemoController.swift; sourceTree = ""; }; + 80B1F7002628D8BB004DFEE5 /* BottomSheetDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetDemoController.swift; sourceTree = ""; }; 8AF03E1F24B6BE3100E6E2A2 /* ContactCollectionViewDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCollectionViewDemoController.swift; sourceTree = ""; }; A589F855211BA71000471C23 /* LabelDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelDemoController.swift; sourceTree = ""; }; A591A3F320F429EB001ED23B /* Demos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demos.swift; sourceTree = ""; }; @@ -267,6 +271,8 @@ B42760DC21488FFA0021A4F7 /* AvatarViewDemoController.swift */, B45EB79121A4D047008646A2 /* BadgeFieldDemoController.swift */, B444D6B72183BA4B0002B4D4 /* BadgeViewDemoController.swift */, + 80AECC0B2630F1BB005AF2F3 /* BottomCommandingDemoController.swift */, + 80B1F7002628D8BB004DFEE5 /* BottomSheetDemoController.swift */, B4D852DA225C010A004B1B29 /* ButtonDemoController.swift */, CCC18C2E2501C75F00BE830E /* CardViewDemoController.swift */, 114CF8B72423E10900D064AA /* ColorDemoController.swift */, @@ -477,6 +483,7 @@ D0F59E3324F4E3A700358DC2 /* PassThroughDrawerDemoController.swift in Sources */, A5DCA760211E3B4C005F4CB7 /* DemoController.swift in Sources */, 7D0931C124AAA3D30072458A /* SideTabBarDemoController.swift in Sources */, + 80B1F7012628D8BB004DFEE5 /* BottomSheetDemoController.swift in Sources */, FC414E3725888BC300069E73 /* CommandBarDemoController.swift in Sources */, B45EB79221A4D047008646A2 /* BadgeFieldDemoController.swift in Sources */, B4EF66562295F729007FEAB0 /* TableViewHeaderFooterSampleData.swift in Sources */, @@ -499,6 +506,7 @@ B498141621E42C140077B48D /* TableViewCellDemoController.swift in Sources */, FDCF7C8321BF35680058E9E6 /* SegmentedControlDemoController.swift in Sources */, C0938E4A235F733100256251 /* ShimmerLinesViewDemoController.swift in Sources */, + 80AECC0C2630F1BB005AF2F3 /* BottomCommandingDemoController.swift in Sources */, C038992E2359307D00265026 /* TableViewCellShimmerDemoController.swift in Sources */, 114CF8B82423E10900D064AA /* ColorDemoController.swift in Sources */, A589F856211BA71000471C23 /* LabelDemoController.swift in Sources */, diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift index adfef65b8..ce9df3154 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift @@ -12,6 +12,8 @@ let demos: [(title: String, controllerClass: UIViewController.Type)] = [ ("AvatarView", AvatarViewDemoController.self), ("BadgeField", BadgeFieldDemoController.self), ("BadgeView", BadgeViewDemoController.self), + ("BottomCommandingController", BottomCommandingDemoController.self), + ("BottomSheetController", BottomSheetDemoController.self), ("Button", ButtonDemoController.self), ("Card", CardViewDemoController.self), ("Color", ColorDemoController.self), diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomCommandingDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomCommandingDemoController.swift new file mode 100644 index 000000000..eaadff6b1 --- /dev/null +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomCommandingDemoController.swift @@ -0,0 +1,255 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import FluentUI + +class BottomCommandingDemoController: UIViewController { + + override func loadView() { + view = UIView() + + let optionTableView = UITableView(frame: .zero, style: .plain) + optionTableView.translatesAutoresizingMaskIntoConstraints = false + optionTableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.identifier) + optionTableView.register(BooleanCell.self, forCellReuseIdentifier: BooleanCell.identifier) + optionTableView.register(ActionsCell.self, forCellReuseIdentifier: ActionsCell.identifier) + optionTableView.dataSource = self + optionTableView.delegate = self + optionTableView.separatorStyle = .none + view.addSubview(optionTableView) + + let bottomCommandingVC = BottomCommandingController() + bottomCommandingVC.heroItems = heroItems + bottomCommandingVC.expandedListSections = expandedListSections + + addChild(bottomCommandingVC) + view.addSubview(bottomCommandingVC.view) + bottomCommandingVC.didMove(toParent: self) + + bottomCommandingController = bottomCommandingVC + + NSLayoutConstraint.activate([ + optionTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + optionTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + optionTableView.topAnchor.constraint(equalTo: view.topAnchor), + optionTableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bottomCommandingVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomCommandingVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomCommandingVC.view.topAnchor.constraint(equalTo: view.topAnchor), + bottomCommandingVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private lazy var heroItems: [CommandingItem] = { + return Array(1...5).map { + CommandingItem(title: "Item " + String($0), image: homeImage, action: commandAction, selectedImage: homeSelectedImage) + } + }() + + private lazy var expandedListSections: [CommandingSection] = [ + CommandingSection(title: "Section 1", items: Array(1...7).map { + CommandingItem(title: "Item " + String($0), image: homeImage, action: commandAction) + }), + CommandingSection(title: "Section 2", items: Array(1...7).map { + CommandingItem(title: "Item " + String($0), image: homeImage, action: commandAction) + }) + ] + + private lazy var demoOptionItems: [DemoItem] = { + return [DemoItem(title: "Expanded list items", type: .boolean, action: #selector(toggleExpandedItems), isOn: true), + DemoItem(title: "Hero command isOn", type: .boolean, action: #selector(toggleHeroCommandOnOff)), + DemoItem(title: "Hero command isEnabled", type: .boolean, action: #selector(toggleHeroCommandEnabled), isOn: true), + DemoItem(title: "List command isEnabled", type: .boolean, action: #selector(toggleListCommandEnabled), isOn: true), + DemoItem(title: "Change hero command titles", type: .action, action: #selector(changeHeroCommandTitle)), + DemoItem(title: "Change hero command images", type: .action, action: #selector(changeHeroCommandIcon)), + DemoItem(title: "Change list command titles", type: .action, action: #selector(changeListCommandTitle)), + DemoItem(title: "Change list command images", type: .action, action: #selector(changeListCommandIcon)), + DemoItem(title: "Hero command count", type: .stepper, action: nil) + ] + }() + + @objc private func toggleExpandedItems() { + if bottomCommandingController?.expandedListSections.count == 0 { + bottomCommandingController?.expandedListSections = expandedListSections + } else { + bottomCommandingController?.expandedListSections = [] + } + } + + private let modifiedCommandIndices: [Int] = [0, 2, 4] + + @objc private func toggleHeroCommandOnOff() { + modifiedCommandIndices.forEach { + heroItems[$0].isOn.toggle() + } + } + + @objc private func toggleHeroCommandEnabled() { + modifiedCommandIndices.forEach { + heroItems[$0].isEnabled.toggle() + } + } + + @objc private func toggleListCommandEnabled() { + modifiedCommandIndices.forEach { + expandedListSections[0].items[$0].isEnabled.toggle() + } + } + + @objc private func changeHeroCommandTitle() { + modifiedCommandIndices.forEach { + heroItems[$0].title = "Item " + String(Int.random(in: 6..<100)) + } + } + + @objc private func changeListCommandTitle() { + modifiedCommandIndices.forEach { + expandedListSections[0].items[$0].title = "Item " + String(Int.random(in: 6..<100)) + } + } + + @objc private func changeHeroCommandIcon() { + modifiedCommandIndices.forEach { + heroItems[$0].image = heroIconChanged ? homeImage : boldImage + heroItems[$0].selectedImage = heroIconChanged ? homeSelectedImage : boldImage + } + heroIconChanged.toggle() + } + + @objc private func changeListCommandIcon() { + modifiedCommandIndices.forEach { + expandedListSections[0].items[$0].image = listIconChanged ? homeImage : boldImage + expandedListSections[0].items[$0].selectedImage = listIconChanged ? homeSelectedImage : boldImage + } + listIconChanged.toggle() + } + + @objc private func incrementHeroCommands() { + let currentCount = bottomCommandingController?.heroItems.count ?? 0 + if currentCount < 5 { + let newCount = currentCount + 1 + bottomCommandingController?.heroItems = Array(heroItems[0.. 1 { + let newCount = currentCount - 1 + bottomCommandingController?.heroItems = Array(heroItems[0.. Bool { + return false + } +} + +extension BottomCommandingDemoController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return demoOptionItems.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item = demoOptionItems[indexPath.row] + + if item.type == .boolean { + guard let cell = tableView.dequeueReusableCell(withIdentifier: BooleanCell.identifier) as? BooleanCell else { + return UITableViewCell() + } + cell.setup(title: item.title, isOn: item.isOn) + cell.titleNumberOfLines = 0 + cell.onValueChanged = { [weak self, weak cell] in + self?.perform(item.action, with: cell) + } + return cell + } else if item.type == .action { + guard let cell = tableView.dequeueReusableCell(withIdentifier: ActionsCell.identifier) as? ActionsCell else { + return UITableViewCell() + } + cell.setup(action1Title: item.title) + if let action = item.action { + cell.action1Button.addTarget(self, action: action, for: .touchUpInside) + } + cell.bottomSeparatorType = .full + return cell + } else if item.type == .stepper { + guard let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.identifier) as? TableViewCell else { + return UITableViewCell() + } + + let stackView = UIStackView(frame: CGRect(x: 0, y: 0, width: 100, height: 40)) + stackView.addArrangedSubview(decrementHeroCommandCountButton) + stackView.addArrangedSubview(incrementHeroCommandCountButton) + stackView.distribution = .fillEqually + stackView.alignment = .center + stackView.spacing = 4 + + cell.setup(title: item.title, customAccessoryView: stackView) + cell.titleNumberOfLines = 0 + return cell + } + + return UITableViewCell() + } +} diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomSheetDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomSheetDemoController.swift new file mode 100644 index 000000000..b840eb0e5 --- /dev/null +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/BottomSheetDemoController.swift @@ -0,0 +1,149 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import FluentUI + +class BottomSheetDemoController: UIViewController { + + override func loadView() { + view = UIView() + + let optionTableView = UITableView(frame: .zero, style: .plain) + optionTableView.translatesAutoresizingMaskIntoConstraints = false + optionTableView.register(BooleanCell.self, forCellReuseIdentifier: BooleanCell.identifier) + optionTableView.register(ActionsCell.self, forCellReuseIdentifier: ActionsCell.identifier) + optionTableView.dataSource = self + optionTableView.delegate = self + optionTableView.separatorStyle = .none + view.addSubview(optionTableView) + + let bottomSheetViewController = BottomSheetController(contentView: personaListView) + bottomSheetViewController.hostedScrollView = personaListView + + self.bottomSheetViewController = bottomSheetViewController + + self.addChild(bottomSheetViewController) + view.addSubview(bottomSheetViewController.view) + bottomSheetViewController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + optionTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + optionTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + optionTableView.topAnchor.constraint(equalTo: view.topAnchor), + optionTableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bottomSheetViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomSheetViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomSheetViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + bottomSheetViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + @objc private func toggleExpandable() { + bottomSheetViewController?.isExpandable.toggle() + } + + @objc private func fullScreenExpandedOffset() { + bottomSheetViewController?.expandedHeightFraction = 1.0 + } + + @objc private func halfScreenExpandedOffset() { + bottomSheetViewController?.expandedHeightFraction = 0.5 + } + + private let personaListView: PersonaListView = { + let personaListView = PersonaListView() + personaListView.personaList = samplePersonas + personaListView.translatesAutoresizingMaskIntoConstraints = false + return personaListView + }() + + private var bottomSheetViewController: BottomSheetController? + + private lazy var demoOptionItems: [DemoItem] = { + return [ + DemoItem(title: "Expandable", type: .boolean, action: #selector(toggleExpandable), isOn: true), + DemoItem(title: "Full screen expansion height", type: .action, action: #selector(fullScreenExpandedOffset)), + DemoItem(title: "Half screen expansion height", type: .action, action: #selector(halfScreenExpandedOffset)) + ] + }() + + private enum DemoItemType { + case action + case boolean + case stepper + } + + private struct DemoItem { + let title: String + let type: DemoItemType + let action: Selector? + var isOn: Bool = false + } +} + +private class BottomSheetPersonaListViewController: UIViewController { + override func loadView() { + view = UIView() + view.addSubview(personaListView) + + NSLayoutConstraint.activate([ + personaListView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + personaListView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + personaListView.topAnchor.constraint(equalTo: view.topAnchor), + personaListView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + public let personaListView: PersonaListView = { + let personaListView = PersonaListView() + personaListView.personaList = samplePersonas + personaListView.translatesAutoresizingMaskIntoConstraints = false + return personaListView + }() +} + +extension BottomSheetDemoController: UITableViewDelegate { + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + return false + } +} + +extension BottomSheetDemoController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return demoOptionItems.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item = demoOptionItems[indexPath.row] + + if item.type == .boolean { + guard let cell = tableView.dequeueReusableCell(withIdentifier: BooleanCell.identifier) as? BooleanCell else { + return UITableViewCell() + } + cell.setup(title: item.title, isOn: item.isOn) + cell.titleNumberOfLines = 0 + cell.onValueChanged = { [weak self, weak cell] in + self?.perform(item.action, with: cell) + } + return cell + } else if item.type == .action { + guard let cell = tableView.dequeueReusableCell(withIdentifier: ActionsCell.identifier) as? ActionsCell else { + return UITableViewCell() + } + cell.setup(action1Title: item.title) + if let action = item.action { + cell.action1Button.addTarget(self, action: action, for: .touchUpInside) + } + cell.bottomSeparatorType = .full + return cell + } + + return UITableViewCell() + } +} diff --git a/ios/FluentUI.xcodeproj/project.pbxproj b/ios/FluentUI.xcodeproj/project.pbxproj index 9e81a8acc..8066c8dcb 100644 --- a/ios/FluentUI.xcodeproj/project.pbxproj +++ b/ios/FluentUI.xcodeproj/project.pbxproj @@ -151,6 +151,16 @@ 7D23482724D88DE600FBE057 /* AvatarGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23482624D88DDF00FBE057 /* AvatarGroupView.swift */; }; 7DC2FB2824C0ED1600367A55 /* TableViewCellFileAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC2FB2724C0ED1100367A55 /* TableViewCellFileAccessoryView.swift */; }; 7DC2FB2D24D209E800367A55 /* Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC2FB2C24D209E300367A55 /* Presence.swift */; }; + 8035CAAC2633A442007B3FD1 /* BottomCommandingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8035CAAA2633A442007B3FD1 /* BottomCommandingController.swift */; }; + 8035CAB62633A4DB007B3FD1 /* BottomCommandingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8035CAAA2633A442007B3FD1 /* BottomCommandingController.swift */; }; + 8035CACB26377C14007B3FD1 /* CommandingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8035CACA26377C14007B3FD1 /* CommandingItem.swift */; }; + 8035CAD026377C17007B3FD1 /* CommandingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8035CACA26377C14007B3FD1 /* CommandingItem.swift */; }; + 8035CADD2638E435007B3FD1 /* CommandingSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8035CADC2638E435007B3FD1 /* CommandingSection.swift */; }; + 8035CADE2638E435007B3FD1 /* CommandingSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8035CADC2638E435007B3FD1 /* CommandingSection.swift */; }; + 80AECBD92629F18E005AF2F3 /* BottomSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AECBD82629F18E005AF2F3 /* BottomSheetController.swift */; }; + 80AECBF2262FC34E005AF2F3 /* BottomSheetPassthroughView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AECBF1262FC34E005AF2F3 /* BottomSheetPassthroughView.swift */; }; + 80AECC21263339E3005AF2F3 /* BottomSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AECBD82629F18E005AF2F3 /* BottomSheetController.swift */; }; + 80AECC22263339E5005AF2F3 /* BottomSheetPassthroughView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AECBF1262FC34E005AF2F3 /* BottomSheetPassthroughView.swift */; }; 86AF4F7525AFC746005D4253 /* PillButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86AF4F7425AFC746005D4253 /* PillButtonStyle.swift */; }; 8A01C86F248FFC5300C971F3 /* ContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A01C86E248FFC5300C971F3 /* ContactView.swift */; }; 8AF03E1A24B6BD4700E6E2A2 /* ContactCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF03E1924B6BD4700E6E2A2 /* ContactCollectionView.swift */; }; @@ -335,6 +345,11 @@ 7D23482624D88DDF00FBE057 /* AvatarGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarGroupView.swift; sourceTree = ""; }; 7DC2FB2724C0ED1100367A55 /* TableViewCellFileAccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellFileAccessoryView.swift; sourceTree = ""; }; 7DC2FB2C24D209E300367A55 /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = ""; }; + 8035CAAA2633A442007B3FD1 /* BottomCommandingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomCommandingController.swift; sourceTree = ""; }; + 8035CACA26377C14007B3FD1 /* CommandingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandingItem.swift; sourceTree = ""; }; + 8035CADC2638E435007B3FD1 /* CommandingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandingSection.swift; sourceTree = ""; }; + 80AECBD82629F18E005AF2F3 /* BottomSheetController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomSheetController.swift; sourceTree = ""; }; + 80AECBF1262FC34E005AF2F3 /* BottomSheetPassthroughView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetPassthroughView.swift; sourceTree = ""; }; 86AF4F7425AFC746005D4253 /* PillButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillButtonStyle.swift; sourceTree = ""; }; 8A01C86E248FFC5300C971F3 /* ContactView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactView.swift; sourceTree = ""; }; 8AF03E1924B6BD4700E6E2A2 /* ContactCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCollectionView.swift; sourceTree = ""; }; @@ -712,6 +727,25 @@ path = xcode; sourceTree = ""; }; + 80B1F6F52628CDEB004DFEE5 /* Bottom Sheet */ = { + isa = PBXGroup; + children = ( + 80AECBD82629F18E005AF2F3 /* BottomSheetController.swift */, + 80AECBF1262FC34E005AF2F3 /* BottomSheetPassthroughView.swift */, + ); + path = "Bottom Sheet"; + sourceTree = ""; + }; + 80B52538264CA5BC00E3FD32 /* Bottom Commanding */ = { + isa = PBXGroup; + children = ( + 8035CAAA2633A442007B3FD1 /* BottomCommandingController.swift */, + 8035CACA26377C14007B3FD1 /* CommandingItem.swift */, + 8035CADC2638E435007B3FD1 /* CommandingSection.swift */, + ); + path = "Bottom Commanding"; + sourceTree = ""; + }; A5961F9B218A251E00E2A506 /* Popup Menu */ = { isa = PBXGroup; children = ( @@ -791,6 +825,8 @@ 5314E00325F009B70099271A /* ActivityViewAnimating */, 5314DFEB25F002240099271A /* ActivityIndicator */, 5314DFF625F0079B0099271A /* BarButtonItems */, + 80B52538264CA5BC00E3FD32 /* Bottom Commanding */, + 80B1F6F52628CDEB004DFEE5 /* Bottom Sheet */, 5314DFEC25F0029C0099271A /* Button */, CCC18C2A2501B1A900BE830E /* Card */, B4F118EA21C8270F00855942 /* Badge Field */, @@ -1391,6 +1427,7 @@ C708B064260A87F7007190FA /* SegmentItem.swift in Sources */, 5314E14325F016860099271A /* CardTransitionAnimator.swift in Sources */, 5314E0F825F012CB0099271A /* LargeTitleView.swift in Sources */, + 8035CAB62633A4DB007B3FD1 /* BottomCommandingController.swift in Sources */, 5314E13725F016370099271A /* PopupMenuProtocols.swift in Sources */, 5314E19725F019650099271A /* TabBarItem.swift in Sources */, 5314E1BB25F01B070099271A /* TouchForwardingView.swift in Sources */, @@ -1403,6 +1440,7 @@ 5314E2A025F024860099271A /* NSLayoutConstraint+Extensions.swift in Sources */, 5314E26625F023B20099271A /* UIColor+Extensions.swift in Sources */, 5314E30225F0260E0099271A /* AccessibilityContainerView.swift in Sources */, + 8035CAD026377C17007B3FD1 /* CommandingItem.swift in Sources */, 8FD01188228A82A600D25925 /* Colors.swift in Sources */, 5314E0F325F012C80099271A /* ShyHeaderController.swift in Sources */, 5314E1B125F01A980099271A /* TooltipView.swift in Sources */, @@ -1450,6 +1488,7 @@ 5314E0A825F010070099271A /* DrawerPresentationController.swift in Sources */, 5314E06425F00EFD0099271A /* CalendarViewMonthBannerView.swift in Sources */, 5314E18E25F0195C0099271A /* ShimmerLinesView.swift in Sources */, + 80AECC21263339E3005AF2F3 /* BottomSheetController.swift in Sources */, 5314E24B25F0232F0099271A /* UIScreen+Extension.swift in Sources */, 5314E2E325F025500099271A /* FluentUIFramework.swift in Sources */, 5314E0EC25F012C40099271A /* NavigationAnimator.swift in Sources */, @@ -1467,6 +1506,7 @@ 5314E1B225F01A980099271A /* TooltipPositionController.swift in Sources */, 5314E08A25F00F2D0099271A /* CommandBar.swift in Sources */, 5314E18D25F0195C0099271A /* ShimmerView.swift in Sources */, + 80AECC22263339E5005AF2F3 /* BottomSheetPassthroughView.swift in Sources */, 5314E1CD25F01B730099271A /* AnimationSynchronizer.swift in Sources */, 5314E13425F016370099271A /* PopupMenuItem.swift in Sources */, 5314E13825F016370099271A /* PopupMenuSection.swift in Sources */, @@ -1474,6 +1514,7 @@ 5314E11625F015EA0099271A /* PersonaBadgeViewDataSource.swift in Sources */, 5314E2DA25F025370099271A /* Fonts.swift in Sources */, 5314E07F25F00F1A0099271A /* DateTimePickerViewComponentCell.swift in Sources */, + 8035CADE2638E435007B3FD1 /* CommandingSection.swift in Sources */, 5314E0E425F012C00099271A /* NavigationController.swift in Sources */, 5314E00D25F00B390099271A /* ActivityIndicatorView.swift in Sources */, 5314E03B25F00E3D0099271A /* BadgeStringExtractor.swift in Sources */, @@ -1533,6 +1574,7 @@ A5B6617323A41E2900E801DD /* NotificationView.swift in Sources */, C708B04C260A8696007190FA /* SegmentItem.swift in Sources */, FD41C88622DD13230086F899 /* ShyHeaderController.swift in Sources */, + 8035CAAC2633A442007B3FD1 /* BottomCommandingController.swift in Sources */, 537315B325438B15001FD14C /* iOS13_4_compatibility.swift in Sources */, FDFB8AF121361C9D0046850A /* CalendarViewDayMonthCell.swift in Sources */, B47B58B822F8E5840078DE38 /* PeoplePicker.swift in Sources */, @@ -1545,9 +1587,11 @@ FDFB8AEB21361C950046850A /* CalendarViewMonthBannerView.swift in Sources */, B4EF66512294A665007FEAB0 /* TableViewHeaderFooterView.swift in Sources */, FD41C8B222DD3BB70086F899 /* UIScrollView+Extensions.swift in Sources */, + 8035CACB26377C14007B3FD1 /* CommandingItem.swift in Sources */, B483323321CC71940022B4CC /* HUDView.swift in Sources */, FD41C89422DD13230086F899 /* LargeTitleView.swift in Sources */, C0938E44235E8ED500256251 /* AnimationSynchronizer.swift in Sources */, + 80AECBD92629F18E005AF2F3 /* BottomSheetController.swift in Sources */, B483323521DEA8D70022B4CC /* HUD.swift in Sources */, C708B056260A86FA007190FA /* SegmentPillButton.swift in Sources */, A5B87B06211E23650038C37C /* UIView+Extensions.swift in Sources */, @@ -1609,6 +1653,7 @@ FD56FD95219131430023C7EA /* DateTimePickerView.swift in Sources */, FDA1AF8C21484625001AE720 /* BlurringView.swift in Sources */, A5961FA1218A25C400E2A506 /* PopupMenuSection.swift in Sources */, + 80AECBF2262FC34E005AF2F3 /* BottomSheetPassthroughView.swift in Sources */, A5B87AF6211E16370038C37C /* DrawerController.swift in Sources */, FD41C89622DD13230086F899 /* NavigationBar.swift in Sources */, A5CEC16D20D98EE70016922A /* Colors.swift in Sources */, @@ -1616,6 +1661,7 @@ FD0D29D62151A3D700E8655E /* CardPresenterNavigationController.swift in Sources */, 8AF03E1C24B6BDBD00E6E2A2 /* ContactCollectionViewCell.swift in Sources */, FD4F2A1B2148937100C437D6 /* PageCardPresenterController.swift in Sources */, + 8035CADD2638E435007B3FD1 /* CommandingSection.swift in Sources */, FD5BBE3B214B2F44008964B4 /* Date+Extensions.swift in Sources */, FD5BBE43214C73CE008964B4 /* EasyTapButton.swift in Sources */, B498141421E424920077B48D /* TableViewCell.swift in Sources */, diff --git a/ios/FluentUI/Bottom Commanding/BottomCommanding.resources.xcfilelist b/ios/FluentUI/Bottom Commanding/BottomCommanding.resources.xcfilelist new file mode 100644 index 000000000..581697a06 --- /dev/null +++ b/ios/FluentUI/Bottom Commanding/BottomCommanding.resources.xcfilelist @@ -0,0 +1 @@ +more-24x24.imageset diff --git a/ios/FluentUI/Bottom Commanding/BottomCommandingController.swift b/ios/FluentUI/Bottom Commanding/BottomCommandingController.swift new file mode 100644 index 000000000..42c3ed953 --- /dev/null +++ b/ios/FluentUI/Bottom Commanding/BottomCommandingController.swift @@ -0,0 +1,589 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +/// Persistent commanding surface displayed at the bottom of the available area. +/// +/// The presentation style automatically varies depending on the current horizontal `UIUserInterfaceSizeClass`: +/// +/// `.unspecified` and `.compact` - the surface is displayed as an expandable bottom sheet. +/// +/// `.regular` - the surface is displayed as a floating bottom bar. +/// +/// In both styles, `heroItems` are always presented in a horizontal stack. +/// Items from the `expandedListSections` are either presented in an expanded sheet or a popover, depending on the current style. +/// +@objc(MSFBottomCommandingController) +open class BottomCommandingController: UIViewController { + + /// Items to be displayed in an area that's always visible. This is either the top of the the sheet, + /// or the main bottom bar area, depending on current horizontal UIUserInterfaceSizeClass. + /// + /// At most 5 hero items are supported. + @objc open var heroItems: [CommandingItem] = [] { + willSet { + clearAllItemViews(in: .heroSet) + } + didSet { + precondition(heroItems.count <= 5, "At most 5 hero commands are supported.") + + if isHeroCommandStackLoaded { + heroItems.forEach { heroCommandStack.addArrangedSubview(createAndBindHeroCommandView(with: $0)) } + } + } + } + + /// Sections with items to be displayed in the list area. + @objc open var expandedListSections: [CommandingSection] = [] { + willSet { + clearAllItemViews(in: .list) + } + didSet { + expandedListSections.forEach { section in + section.items.forEach { $0.delegate = self } + } + if isTableViewLoaded { + // Item views and bindings will be lazily created during UITableView cellForRowAt + tableView.reloadData() + } + updateExpandability() + } + } + + // MARK: - View building and layout + + public override func loadView() { + view = BottomSheetPassthroughView() + view.translatesAutoresizingMaskIntoConstraints = false + + if traitCollection.horizontalSizeClass == .regular { + setupBottomBarLayout() + } else { + setupBottomSheetLayout() + } + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass else { + return + } + + // On a horizontal size class change the top level sheet / bar surfaces get recreated, + // but the item views, containers and bindings persist and are rearranged during the individual setup functions. + if let bottomSheetController = bottomSheetController { + bottomSheetController.willMove(toParent: nil) + bottomSheetController.removeFromParent() + bottomSheetController.view.removeFromSuperview() + } + bottomSheetController = nil + bottomBarView?.removeFromSuperview() + bottomBarView = nil + + if traitCollection.horizontalSizeClass == .regular { + setupBottomBarLayout() + } else { + setupBottomSheetLayout() + } + } + + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + if presentedViewController != nil { + dismiss(animated: false) + } + } + + private func setupBottomBarLayout() { + NSLayoutConstraint.activate(heroCommandWidthConstraints) + heroCommandStack.distribution = .equalSpacing + + let commandContainer = UIStackView() + commandContainer.translatesAutoresizingMaskIntoConstraints = false + commandContainer.addArrangedSubview(heroCommandStack) + commandContainer.addArrangedSubview(moreButtonView) + + let bottomBarView = makeBottomBarByEmbedding(contentView: commandContainer) + bottomBarView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomBarView) + + NSLayoutConstraint.activate([ + bottomBarView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + bottomBarView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -Constants.BottomBar.bottomOffset) + ]) + + self.bottomBarView = bottomBarView + updateExpandability() + } + + private func setupBottomSheetLayout() { + NSLayoutConstraint.deactivate(heroCommandWidthConstraints) + heroCommandStack.distribution = .fillEqually + + let commandStackContainer = UIView() + commandStackContainer.addSubview(heroCommandStack) + + let sheetController = BottomSheetController(contentView: makeBottomSheetContent(headerView: commandStackContainer, expandedContentView: tableView)) + sheetController.hostedScrollView = tableView + sheetController.collapsedContentHeight = bottomSheetHeroStackHeight + sheetController.expandedHeightFraction = Constants.BottomSheet.expandedFraction + + addChild(sheetController) + view.addSubview(sheetController.view) + sheetController.didMove(toParent: self) + + // We need to keep a reference to this because the margin changes based on expandability + let heroStackTopConstraint = heroCommandStack.topAnchor.constraint(equalTo: commandStackContainer.topAnchor, constant: bottomSheetHeroStackTopMargin) + bottomSheetHeroStackTopConstraint = heroStackTopConstraint + + NSLayoutConstraint.activate([ + sheetController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + sheetController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sheetController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sheetController.view.topAnchor.constraint(equalTo: view.topAnchor), + heroCommandStack.leadingAnchor.constraint(equalTo: commandStackContainer.leadingAnchor, constant: Constants.BottomSheet.heroStackLeadingTrailingMargin), + heroCommandStack.trailingAnchor.constraint(equalTo: commandStackContainer.trailingAnchor, constant: -Constants.BottomSheet.heroStackLeadingTrailingMargin), + heroCommandStack.bottomAnchor.constraint(equalTo: commandStackContainer.bottomAnchor, constant: -Constants.BottomSheet.heroStackBottomMargin), + heroStackTopConstraint + ]) + + bottomSheetController = sheetController + updateExpandability() + } + + private func makeBottomBarByEmbedding(contentView: UIView) -> UIView { + let bottomBarView = UIView() + let bottomBarLayer = bottomBarView.layer + bottomBarLayer.shadowColor = Constants.BottomBar.Shadow.color + bottomBarLayer.shadowOpacity = Constants.BottomBar.Shadow.opacity + bottomBarLayer.shadowRadius = Constants.BottomBar.Shadow.radius + + let roundedCornerView = UIView() + roundedCornerView.backgroundColor = Constants.BottomBar.backgroundColor + roundedCornerView.translatesAutoresizingMaskIntoConstraints = false + roundedCornerView.layer.cornerRadius = Constants.BottomBar.cornerRadius + roundedCornerView.layer.cornerCurve = .continuous + roundedCornerView.clipsToBounds = true + + bottomBarView.addSubview(roundedCornerView) + roundedCornerView.addSubview(contentView) + + NSLayoutConstraint.activate([ + roundedCornerView.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor), + roundedCornerView.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor), + roundedCornerView.topAnchor.constraint(equalTo: bottomBarView.topAnchor), + roundedCornerView.bottomAnchor.constraint(equalTo: bottomBarView.bottomAnchor), + contentView.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor, constant: Constants.BottomBar.heroStackLeadingTrailingMargin), + contentView.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor, constant: -Constants.BottomBar.heroStackLeadingTrailingMargin), + contentView.topAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: Constants.BottomBar.heroStackTopBottomMargin), + contentView.bottomAnchor.constraint(equalTo: bottomBarView.bottomAnchor, constant: -Constants.BottomBar.heroStackTopBottomMargin) + ]) + + return bottomBarView + } + + private func makeBottomSheetContent(headerView: UIView, expandedContentView: UIView) -> UIView { + let view = UIView() + let separator = Separator() + separator.translatesAutoresizingMaskIntoConstraints = false + headerView.translatesAutoresizingMaskIntoConstraints = false + expandedContentView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(headerView) + view.addSubview(expandedContentView) + view.addSubview(separator) + NSLayoutConstraint.activate([ + headerView.topAnchor.constraint(equalTo: view.topAnchor), + headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + expandedContentView.topAnchor.constraint(equalTo: headerView.bottomAnchor), + expandedContentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + expandedContentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + expandedContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + separator.topAnchor.constraint(equalTo: expandedContentView.topAnchor), + separator.leadingAnchor.constraint(equalTo: view.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + return view + } + + private func updateExpandability() { + if isInSheetMode { + bottomSheetController?.collapsedContentHeight = bottomSheetHeroStackHeight + bottomSheetController?.isExpandable = isExpandable + bottomSheetHeroStackTopConstraint?.constant = bottomSheetHeroStackTopMargin + } else { + moreButtonView.isHidden = !isExpandable + } + } + + private lazy var moreButtonView: UIView = { + let moreButtonItem = TabBarItem(title: Constants.BottomBar.moreButtonTitle, image: Constants.BottomBar.moreButtonIcon ?? UIImage()) + let moreButtonView = TabBarItemView(item: moreButtonItem, showsTitle: true) + moreButtonView.alwaysShowTitleBelowImage = true + moreButtonView.accessibilityTraits.insert(.button) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleMoreButtonTap(_:))) + moreButtonView.addGestureRecognizer(tapGesture) + + NSLayoutConstraint.activate([ + moreButtonView.widthAnchor.constraint(equalToConstant: Constants.heroButtonWidth), + moreButtonView.heightAnchor.constraint(equalToConstant: Constants.heroButtonHeight) + ]) + + return moreButtonView + }() + + private lazy var heroCommandStack: UIStackView = { + let itemViews = heroItems.map { createAndBindHeroCommandView(with: $0) } + let stackView = UIStackView(arrangedSubviews: itemViews) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.addInteraction(UILargeContentViewerInteraction()) + + isHeroCommandStackLoaded = true + return stackView + }() + + private lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .grouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.separatorStyle = .none + tableView.alwaysBounceVertical = false + tableView.sectionFooterHeight = 0 + tableView.backgroundColor = Constants.tableViewBackgroundColor + tableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.identifier) + tableView.register(TableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: TableViewHeaderFooterView.identifier) + tableView.delegate = self + tableView.dataSource = self + + isTableViewLoaded = true + return tableView + }() + + // MARK: - Command tap handling + + @objc private func handleHeroCommandTap(_ sender: UITapGestureRecognizer) { + guard let tabBarItemView = sender.view as? TabBarItemView, let binding = viewToBindingMap[tabBarItemView] else { + return + } + let item = binding.item + if item.isToggleable { + tabBarItemView.isSelected.toggle() + item.isOn = tabBarItemView.isSelected + } + item.action(binding.item) + } + + @objc private func handleMoreButtonTap(_ sender: UITapGestureRecognizer) { + let popoverContentViewController = UIViewController() + popoverContentViewController.view = tableView + popoverContentViewController.modalPresentationStyle = .popover + popoverContentViewController.popoverPresentationController?.sourceView = sender.view + + present(popoverContentViewController, animated: true) + } + + // MARK: - Item <-> View Binding + + private func addBinding(_ binding: ItemBindingInfo) { + itemToBindingMap[binding.item] = binding + viewToBindingMap[binding.view] = binding + } + + private func removeBinding(_ binding: ItemBindingInfo) { + itemToBindingMap.removeValue(forKey: binding.item) + viewToBindingMap.removeValue(forKey: binding.view) + } + + private func clearAllItemViews(in location: ItemLocation) { + switch location { + case .heroSet: + heroItems.forEach { + if let binding = itemToBindingMap[$0] { + removeBinding(binding) + } + } + heroCommandStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + case .list: + expandedListSections.forEach { + $0.items.forEach { + if let binding = itemToBindingMap[$0] { + removeBinding(binding) + } + } + } + } + } + + private func createAndBindHeroCommandView(with item: CommandingItem) -> UIView { + let tabItem = TabBarItem(title: item.title, image: item.image, selectedImage: item.selectedImage, largeContentImage: item.largeImage) + let itemView = TabBarItemView(item: tabItem, showsTitle: true) + itemView.alwaysShowTitleBelowImage = true + itemView.numberOfTitleLines = 1 + itemView.isSelected = item.isOn + itemView.accessibilityTraits.insert(.button) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleHeroCommandTap(_:))) + itemView.addGestureRecognizer(tapGesture) + + NSLayoutConstraint.activate([ + itemView.heightAnchor.constraint(equalToConstant: Constants.heroButtonHeight) + ]) + let widthConstraint = itemView.widthAnchor.constraint(equalToConstant: Constants.heroButtonWidth) + widthConstraint.isActive = !isInSheetMode + + item.delegate = self + let binding = HeroItemBindingInfo(item: item, view: itemView, location: .heroSet, widthConstraint: widthConstraint) + addBinding(binding) + + return itemView + } + + private func setupTableViewCell(_ cell: TableViewCell, with item: CommandingItem) { + let iconView = UIImageView(image: item.image) + iconView.tintColor = Constants.tableViewIconTintColor + cell.setup(title: item.title, subtitle: "", footer: "", customView: iconView, customAccessoryView: nil, accessoryType: .none) + cell.isEnabled = item.isEnabled + cell.backgroundColor = Constants.tableViewBackgroundColor + + let shouldShowSeparator = expandedListSections + .prefix(expandedListSections.count - 1) + .contains(where: { $0.items.last == item }) + cell.bottomSeparatorType = shouldShowSeparator ? .full : .none + } + + // Reloads view in place from the given item object + private func reloadView(from item: CommandingItem) { + guard let binding = itemToBindingMap[item] else { + return + } + let staleView = binding.view + + switch binding.location { + case .heroSet: + if let stackIndex = heroCommandStack.arrangedSubviews.firstIndex(of: staleView) { + removeBinding(binding) + let newView = createAndBindHeroCommandView(with: item) + staleView.removeFromSuperview() + heroCommandStack.insertArrangedSubview(newView, at: stackIndex) + } + case .list: + if let cell = binding.view as? TableViewCell { + setupTableViewCell(cell, with: item) + } + } + } + + private var itemToBindingMap: [CommandingItem: ItemBindingInfo] = [:] + + private var viewToBindingMap: [UIView: ItemBindingInfo] = [:] + + private var bottomBarView: UIView? + + private var bottomSheetController: BottomSheetController? + + private var isHeroCommandStackLoaded: Bool = false + + private var isTableViewLoaded: Bool = false + + private var isInSheetMode: Bool { bottomSheetController != nil } + + private var isExpandable: Bool { expandedListSections.count > 0 } + + private var bottomSheetHeroStackTopConstraint: NSLayoutConstraint? + + private var bottomSheetHeroStackTopMargin: CGFloat { + isExpandable ? Constants.BottomSheet.heroStackExpandableTopMargin : Constants.BottomSheet.heroStackNonExpandableTopMargin + } + + private var bottomSheetHeroStackHeight: CGFloat { Constants.heroButtonHeight + Constants.BottomSheet.heroStackBottomMargin + bottomSheetHeroStackTopMargin } + + private var heroCommandWidthConstraints: [NSLayoutConstraint] { + heroItems.compactMap { (itemToBindingMap[$0] as? HeroItemBindingInfo)?.widthConstraint } + } + + private enum ItemLocation { + case heroSet + case list + } + + private class ItemBindingInfo { + let item: CommandingItem + let view: UIView + let location: ItemLocation + + init(item: CommandingItem, view: UIView, location: ItemLocation) { + self.item = item + self.view = view + self.location = location + } + } + + private class HeroItemBindingInfo: ItemBindingInfo { + let widthConstraint: NSLayoutConstraint + + init(item: CommandingItem, view: UIView, location: ItemLocation, widthConstraint: NSLayoutConstraint) { + self.widthConstraint = widthConstraint + super.init(item: item, view: view, location: location) + } + } + + private struct Constants { + static let heroButtonHeight: CGFloat = 48 + static let heroButtonWidth: CGFloat = 96 + + static let tableViewIconTintColor: UIColor = Colors.textSecondary + static let tableViewBackgroundColor: UIColor = Colors.NavigationBar.background + + struct BottomBar { + static let cornerRadius: CGFloat = 14 + static let backgroundColor: UIColor = Colors.NavigationBar.background + + static let bottomOffset: CGFloat = 10 + static let heroStackLeadingTrailingMargin: CGFloat = 8 + static let heroStackTopBottomMargin: CGFloat = 16 + + static let moreButtonIcon: UIImage? = UIImage.staticImageNamed("more-24x24") + static let moreButtonTitle: String = "CommandingBottomBar.More".localized + + struct Shadow { + static let color: CGColor = UIColor.black.cgColor + static let opacity: Float = 0.14 + static let radius: CGFloat = 8 + } + } + + struct BottomSheet { + static let expandedFraction: CGFloat = 0.7 // Probably should be more customizable / based on content + static let heroStackBottomMargin: CGFloat = 16 + static let heroStackExpandableTopMargin: CGFloat = 0 + static let heroStackNonExpandableTopMargin: CGFloat = 16 + static let heroStackLeadingTrailingMargin: CGFloat = 8 + } + } + +} + +extension BottomCommandingController: UITableViewDataSource { + public func numberOfSections(in tableView: UITableView) -> Int { + return expandedListSections.count + } + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + precondition(section < expandedListSections.count) + + return expandedListSections[section].items.count + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.identifier) as? TableViewCell else { + return UITableViewCell() + } + + let section = expandedListSections[indexPath.section] + let item = section.items[indexPath.row] + setupTableViewCell(cell, with: item) + + // Cells get reused and we sometimes modify them directly, + // so it's important to remove old bindings to avoid side effects + if let oldBinding = viewToBindingMap[cell] { + removeBinding(oldBinding) + } + addBinding(ItemBindingInfo(item: item, view: cell, location: .list)) + + return cell + } +} + +extension BottomCommandingController: UITableViewDelegate { + public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: TableViewHeaderFooterView.identifier) as? TableViewHeaderFooterView else { + return nil + } + let section = expandedListSections[section] + + var configuredHeader: UIView? + if let sectionTitle = section.title { + header.setup(style: .header, title: sectionTitle) + configuredHeader = header + } + + return configuredHeader + } + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let cell = tableView.cellForRow(at: indexPath), let binding = viewToBindingMap[cell] else { + return + } + if presentedViewController != nil { + dismiss(animated: true) + } + + binding.item.action(binding.item) + tableView.deselectRow(at: indexPath, animated: true) + } +} + +extension BottomCommandingController: CommandingItemDelegate { + func commandingItem(_ item: CommandingItem, didChangeTitleTo value: String) { + reloadView(from: item) + } + + func commandingItem(_ item: CommandingItem, didChangeImageTo value: UIImage) { + reloadView(from: item) + } + + func commandingItem(_ item: CommandingItem, didChangeSelectedImageTo value: UIImage?) { + reloadView(from: item) + } + + func commandingItem(_ item: CommandingItem, didChangeLargeImageTo value: UIImage?) { + reloadView(from: item) + } + + func commandingItem(_ item: CommandingItem, didChangeToggleableTo value: Bool) { + reloadView(from: item) + } + + func commandingItem(_ item: CommandingItem, didChangeEnabledTo value: Bool) { + guard let view = itemToBindingMap[item]?.view else { + return + } + + switch view { + case let tabBarItemView as TabBarItemView: + if tabBarItemView.isEnabled != value { + tabBarItemView.isEnabled = value + } + case let cell as TableViewCell: + if cell.isEnabled != value { + cell.isEnabled = value + } + default: + break + } + } + + func commandingItem(_ item: CommandingItem, didChangeOnTo value: Bool) { + guard let view = itemToBindingMap[item]?.view else { + return + } + + switch view { + case let tabBarItemView as TabBarItemView: + if tabBarItemView.isSelected != value { + tabBarItemView.isSelected = value + } + case let booleanCell as BooleanCell: + if booleanCell.isOn != value { + booleanCell.isOn = value + } + default: + break + } + } +} diff --git a/ios/FluentUI/Bottom Commanding/CommandingItem.swift b/ios/FluentUI/Bottom Commanding/CommandingItem.swift new file mode 100644 index 000000000..dba16b24f --- /dev/null +++ b/ios/FluentUI/Bottom Commanding/CommandingItem.swift @@ -0,0 +1,127 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +/// An object representing a command. +/// +/// `CommandingItem` defines the high level properties and behavior of a command. Its visual representation is determined by +/// the `BottomCommandingController`. +@objc(MSFCommandingItem) +open class CommandingItem: NSObject { + + /// A closure that's called when the command is triggered + @objc open var action: (CommandingItem) -> Void + + /// The title of the command item. + @objc open var title: String { + didSet { + if title != oldValue { + delegate?.commandingItem(self, didChangeTitleTo: title) + } + } + } + + /// A `UIImage` to be displayed with the command. + @objc open var image: UIImage { + didSet { + if image != oldValue { + delegate?.commandingItem(self, didChangeImageTo: image) + } + } + } + + /// A `UIImage` used when the command is represented as a button in selected state. + @objc open var selectedImage: UIImage? { + didSet { + if selectedImage != oldValue { + delegate?.commandingItem(self, didChangeSelectedImageTo: selectedImage) + } + } + } + + /// Used in large content viewer if this command is represented using a view that cannot scale with Dynamic Type. + /// + /// When this is `nil`, `image` will be used instead. + @objc open var largeImage: UIImage? { + didSet { + if largeImage != oldValue { + delegate?.commandingItem(self, didChangeLargeImageTo: largeImage) + } + } + } + + /// Indicates whether the command is currently on. + /// + /// When `isToggleable` is `true`, this property is toggled automatically before `action` is called. + @objc open var isOn: Bool { + didSet { + if isOn != oldValue { + delegate?.commandingItem(self, didChangeOnTo: isOn) + } + } + } + + /// Indicates whether the command is enabled. + @objc open var isEnabled: Bool { + didSet { + if isEnabled != oldValue { + delegate?.commandingItem(self, didChangeEnabledTo: isEnabled) + } + } + } + + /// Indicates whether `isOn` should be toggled automatically before `action` is called. + @objc open var isToggleable: Bool { + didSet { + if isToggleable != oldValue { + delegate?.commandingItem(self, didChangeToggleableTo: isToggleable) + } + } + } + + @objc public init(title: String, + image: UIImage, + action: @escaping (CommandingItem) -> Void, + selectedImage: UIImage? = nil, + largeImage: UIImage? = nil, + isSelected: Bool = false, + isEnabled: Bool = true, + isToggleable: Bool = false) { + self.title = title + self.action = action + self.image = image + self.selectedImage = selectedImage + self.largeImage = largeImage + self.isOn = isSelected + self.isEnabled = isEnabled + self.isToggleable = isToggleable + } + + weak var delegate: CommandingItemDelegate? +} + +protocol CommandingItemDelegate: class { + /// Called after the `title` property changed. + func commandingItem(_ item: CommandingItem, didChangeTitleTo value: String) + + /// Called after the `image` property changed. + func commandingItem(_ item: CommandingItem, didChangeImageTo value: UIImage) + + /// Called after the `largeImage` property changed. + func commandingItem(_ item: CommandingItem, didChangeLargeImageTo value: UIImage?) + + /// Called after the `selectedImage` property changed. + func commandingItem(_ item: CommandingItem, didChangeSelectedImageTo value: UIImage?) + + /// Called after the `isOn` property changed. + func commandingItem(_ item: CommandingItem, didChangeOnTo value: Bool) + + /// Called after the `isEnabled` property changed. + func commandingItem(_ item: CommandingItem, didChangeEnabledTo value: Bool) + + /// Called after the `isToggleable` property changed. + func commandingItem(_ item: CommandingItem, didChangeToggleableTo value: Bool) +} diff --git a/ios/FluentUI/Bottom Commanding/CommandingSection.swift b/ios/FluentUI/Bottom Commanding/CommandingSection.swift new file mode 100644 index 000000000..610b707f3 --- /dev/null +++ b/ios/FluentUI/Bottom Commanding/CommandingSection.swift @@ -0,0 +1,23 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +/// A named container of `CommandingItem` objects. +@objc(MSFCommandingSection) +open class CommandingSection: NSObject { + + /// The title of the section. + @objc public let title: String? + + /// An `Array` of `CommandingItem` objects. + @objc public var items: [CommandingItem] + + /// Initializes a commanding section. + @objc public init(title: String?, items: [CommandingItem] = []) { + self.title = title + self.items = items + } +} diff --git a/ios/FluentUI/Bottom Sheet/BottomSheetController.swift b/ios/FluentUI/Bottom Sheet/BottomSheetController.swift new file mode 100644 index 000000000..e6ebc90db --- /dev/null +++ b/ios/FluentUI/Bottom Sheet/BottomSheetController.swift @@ -0,0 +1,481 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +@objc(MSFBottomSheetControllerDelegate) +public protocol BottomSheetControllerDelegate: AnyObject { + /// Called after the sheet fully expanded. + @objc optional func bottomSheetControllerDidExpand(_ controller: BottomSheetController) + + /// Called after the sheet fully collapsed. + @objc optional func bottomSheetControllerDidCollapse(_ controller: BottomSheetController) +} + +@objc(MSFBottomSheetController) +public class BottomSheetController: UIViewController { + + /// Initializes the bottom sheet controller. + /// - Parameter contentViewController: The view controller that's placed inside the bottom sheet. + /// + /// By default the root view of `contentViewController` will be sized automatically to fill the available area, + /// respecting the provided `preferredExpandedHeightFraction` multiplier. + /// Alternatively, the content can size itself by setting `respectsPreferredContentSize` to true and providing a `preferredContentSize`. + @objc public init(contentViewController: UIViewController) { + self.contentViewController = contentViewController + self.contentView = nil + super.init(nibName: nil, bundle: nil) + } + + /// Initializes the bottom sheet controller. + /// - Parameter contentView: The view that's placed inside the bottom sheet. + /// + /// TODO: Add view-specific sizing info + @objc public init(contentView: UIView) { + self.contentView = contentView + self.contentViewController = nil + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + preconditionFailure("init(coder:) has not been implemented") + } + + /// A scroll view in `contentViewController`'s view hierarchy. + /// Provide this to ensure the bottom sheet pan gesture recognizer coordinates with the scroll view to enable scrolling based on current bottom sheet position and content offset. + @objc open var hostedScrollView: UIScrollView? + + /// Indicates if the bottom sheet is expandable. + @objc open var isExpandable: Bool = true { + didSet { + if isExpandable != oldValue { + resizingHandleView.isHidden = !isExpandable + panGestureRecognizer.isEnabled = isExpandable + if isViewLoaded { + move(to: .collapsed, animated: false) + } + } + } + } + + /// Indicates if `preferredContentSize` of `contentViewController` should be respected. + /// Regardless of the value, the expanded height is limited by the height of `BottomSheetController`'s root view. + @objc open var respectsPreferredContentSize: Bool = false { + didSet { + if respectsPreferredContentSize != oldValue { + updateBottomSheetHeightConstraints() + } + } + } + + /// Fraction of the available area that the bottom sheet should take up in the expanded position. + /// + /// Ignored when `respectsPreferredContentSize` is set to `true` + @objc open var expandedHeightFraction: CGFloat = 1.0 { + didSet { + if expandedHeightFraction != oldValue && !respectsPreferredContentSize { + updateBottomSheetHeightConstraints() + } + } + } + + /// Height of the top portion of the content view that should be visible when the bottom sheet is collapsed. + @objc open var collapsedContentHeight: CGFloat = Constants.defaultCollapsedContentHeight { + didSet { + if isViewLoaded { + move(to: .collapsed, animated: false) + } + } + } + + /// The object that acts as the delegate of the bottom sheet. + @objc open weak var delegate: BottomSheetControllerDelegate? + + // MARK: - View loading + + /// View hierarchy + /// --BottomSheetPassthroughView (full overlay area) + /// ----bottomSheetView (bottom sheet area only) + /// ------bottomSheetContentView + /// --------UIStackView + /// ----------ResizingHandleView + /// ----------contentView (root of contentViewController) + public override func loadView() { + view = BottomSheetPassthroughView() + + if let contentViewController = contentViewController { + addChild(contentViewController) + contentViewController.didMove(toParent: self) + } + + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomSheetView) + + NSLayoutConstraint.activate([ + bottomSheetView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomSheetView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomSheetOffsetConstraint + ]) + updateBottomSheetHeightConstraints() + updateResizingHandleViewAccessibility() + } + + private lazy var resizingHandleView: ResizingHandleView = { + let resizingHandleView = ResizingHandleView() + resizingHandleView.isAccessibilityElement = true + resizingHandleView.accessibilityTraits = .button + resizingHandleView.isUserInteractionEnabled = true + resizingHandleView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleResizingHandleViewTap))) + return resizingHandleView + }() + + private lazy var bottomSheetView: UIView = { + let bottomSheetContentView = UIView() + bottomSheetContentView.translatesAutoresizingMaskIntoConstraints = false + + bottomSheetContentView.addGestureRecognizer(panGestureRecognizer) + panGestureRecognizer.delegate = self + + let stackView = UIStackView(arrangedSubviews: [resizingHandleView]) + stackView.spacing = 0.0 + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + + let contentView = contentViewController?.view ?? self.contentView + if let contentView = contentView { + stackView.addArrangedSubview(contentView) + } + + bottomSheetContentView.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: bottomSheetContentView.topAnchor), + stackView.leadingAnchor.constraint(equalTo: bottomSheetContentView.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: bottomSheetContentView.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomSheetContentView.bottomAnchor, constant: -Constants.Spring.overflowHeight) + ]) + + return makeBottomSheetByEmbedding(contentView: bottomSheetContentView) + }() + + private func makeBottomSheetByEmbedding(contentView: UIView) -> UIView { + let bottomSheetView = UIView() + bottomSheetView.translatesAutoresizingMaskIntoConstraints = false + + // We need to have the shadow on a parent of the view that does the corner masking. + // Otherwise the view will mask its own shadow. + bottomSheetView.layer.shadowColor = Constants.Shadow.color + bottomSheetView.layer.shadowOffset = Constants.Shadow.offset + bottomSheetView.layer.shadowOpacity = Constants.Shadow.opacity + bottomSheetView.layer.shadowRadius = Constants.Shadow.radius + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = Colors.NavigationBar.background + contentView.layer.cornerRadius = Constants.cornerRadius + contentView.layer.cornerCurve = .continuous + contentView.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner] + contentView.clipsToBounds = true + + bottomSheetView.addSubview(contentView) + + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: bottomSheetView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: bottomSheetView.trailingAnchor), + contentView.topAnchor.constraint(equalTo: bottomSheetView.topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomSheetView.bottomAnchor) + ]) + + return bottomSheetView + } + + public override func viewDidLayoutSubviews() { + if needsExpandedOffsetUpdate { + needsExpandedOffsetUpdate = false + move(to: .expanded, animated: false, velocity: 0.0) + } + } + + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + if size.height != view.frame.height { + if currentOffsetFromBottom == expandedOffsetFromBottom { + // Recalculate the offset after the next layout pass + needsExpandedOffsetUpdate = true + } else if currentOffsetFromBottom != collapsedContentHeight { + // Safe default for strange edge cases where we are between states + move(to: .collapsed, animated: false, velocity: 0) + } + } + } + + // MARK: - Gesture handling + + @objc private func handleResizingHandleViewTap(_ sender: UITapGestureRecognizer) { + if currentOffsetFromBottom != collapsedOffsetFromBottom { + animate(to: .collapsed, velocity: 0) + hostedScrollView?.setContentOffset(.zero, animated: true) + } else { + animate(to: .expanded, velocity: 0) + } + } + + private func updateResizingHandleViewAccessibility() { + if currentOffsetFromBottom != collapsedOffsetFromBottom { + resizingHandleView.accessibilityLabel = "Accessibility.Drawer.ResizingHandle.Label.Collapse".localized + resizingHandleView.accessibilityHint = "Accessibility.Drawer.ResizingHandle.Hint.Collapse".localized + } else { + resizingHandleView.accessibilityLabel = "Accessibility.Drawer.ResizingHandle.Label.Expand".localized + resizingHandleView.accessibilityHint = "Accessibility.Drawer.ResizingHandle.Hint.Expand".localized + } + } + + @objc private func handlePan(_ sender: UIPanGestureRecognizer) { + switch sender.state { + case .began: + stopAnimationIfNeeded() + fallthrough + case .changed: + translateSheet(by: sender.translation(in: view)) + sender.setTranslation(.zero, in: view) + case .ended, .cancelled, .failed: + completePan(with: sender.velocity(in: view).y) + default: + break + } + } + + private func translateSheet(by translationDelta: CGPoint) { + let maxOffset = expandedOffsetFromBottom + Constants.maxRubberBandOffset + let minOffset = collapsedOffsetFromBottom - Constants.maxRubberBandOffset + + var offsetDelta = translationDelta.y + if currentOffsetFromBottom <= collapsedOffsetFromBottom || currentOffsetFromBottom >= expandedOffsetFromBottom { + offsetDelta *= translationRubberBandFactor(for: currentOffsetFromBottom) + } + bottomSheetOffsetConstraint.constant = -min(max(currentOffsetFromBottom - offsetDelta, minOffset), maxOffset) + } + + private func translationRubberBandFactor(for currentOffset: CGFloat) -> CGFloat { + var offLimitsOffset: CGFloat = 0.0 + if currentOffset > expandedOffsetFromBottom { + offLimitsOffset = min(currentOffset - expandedOffsetFromBottom, Constants.maxRubberBandOffset) + } else if currentOffset < collapsedOffsetFromBottom { + offLimitsOffset = min(collapsedOffsetFromBottom - currentOffset, Constants.maxRubberBandOffset) + } + + return max(1.0 - offLimitsOffset / Constants.maxRubberBandOffset, Constants.minRubberBandScaleFactor) + } + + // MARK: - Animations + + private func completePan(with velocity: CGFloat) { + var targetState: BottomSheetExpansionState + if abs(velocity) < Constants.directionOverrideVelocityThreshold { + // Velocity too low, snap to the closest offset + targetState = + abs(collapsedOffsetFromBottom - currentOffsetFromBottom) < abs(expandedOffsetFromBottom - currentOffsetFromBottom) + ? .collapsed + : .expanded + } else { + // Velocity high enough, animate to the offset we're swiping towards + targetState = velocity > 0 ? .collapsed : .expanded + } + move(to: targetState, velocity: velocity) + } + + private func move(to targetExpansionState: BottomSheetExpansionState, animated: Bool = true, velocity: CGFloat = 0.0) { + let targetOffsetFromBottom = targetExpansionState == .expanded ? expandedOffsetFromBottom : collapsedOffsetFromBottom + if currentOffsetFromBottom != targetOffsetFromBottom { + if animated { + animate(to: targetExpansionState, velocity: velocity) + } else { + bottomSheetOffsetConstraint.constant = -targetOffsetFromBottom + handleCompletedStateChange(to: targetExpansionState) + } + } + } + + private func animate(to targetExpansionState: BottomSheetExpansionState, velocity: CGFloat = 0.0) { + let targetOffsetFromBottom = targetExpansionState == .expanded ? expandedOffsetFromBottom : collapsedOffsetFromBottom + let distanceToGo = abs(currentOffsetFromBottom - targetOffsetFromBottom) + let springVelocity = min(abs(velocity / distanceToGo), Constants.Spring.maxInitialVelocity) + let damping: CGFloat = abs(velocity) > Constants.Spring.flickVelocityThreshold + ? Constants.Spring.oscillatingDampingRatio + : Constants.Spring.defaultDampingRatio + + let springParams = UISpringTimingParameters(dampingRatio: damping, initialVelocity: CGVector(dx: 0.0, dy: springVelocity)) + translationAnimator = UIViewPropertyAnimator(duration: Constants.Spring.animationDuration, timingParameters: springParams) + + view.layoutIfNeeded() + bottomSheetOffsetConstraint.constant = -targetOffsetFromBottom + translationAnimator?.addAnimations { + self.view.layoutIfNeeded() + } + + translationAnimator?.addCompletion({ finalPosition in + if finalPosition == .end { + self.handleCompletedStateChange(to: targetExpansionState) + } + }) + translationAnimator?.startAnimation() + } + + private func handleCompletedStateChange(to targetExpansionState: BottomSheetExpansionState) { + switch targetExpansionState { + case .expanded: + self.delegate?.bottomSheetControllerDidExpand?(self) + case .collapsed: + self.delegate?.bottomSheetControllerDidCollapse?(self) + } + updateResizingHandleViewAccessibility() + } + + private func stopAnimationIfNeeded() { + guard let animator = translationAnimator else { + return + } + + if animator.isRunning { + animator.stopAnimation(true) + + // The AutoLayout constant doesn't animate, so we need to set it to whatever it should be + // based on the frame calculated during the interrupted animation + let offsetFromBottom = view.frame.height - bottomSheetView.frame.origin.y + bottomSheetOffsetConstraint.constant = -offsetFromBottom + } + } + + // MARK: - Height constraint utils + + private func updateBottomSheetHeightConstraints() { + let newConstraints = generateBottomSheetHeightConstraints() + + NSLayoutConstraint.deactivate(bottomSheetHeightConstraints) + NSLayoutConstraint.activate(newConstraints) + + bottomSheetHeightConstraints = newConstraints + } + + private func generateBottomSheetHeightConstraints() -> [NSLayoutConstraint] { + var constraints: [NSLayoutConstraint] + if respectsPreferredContentSize, + let contentViewController = contentViewController { + // Convert child VC preferred height to a constraint + an upper bound constraint + let preferredHeightConstraint = contentViewController.view.heightAnchor.constraint(equalToConstant: contentViewController.preferredContentSize.height) + preferredHeightConstraint.priority = .defaultLow + + let maxHeightConstraint = bottomSheetView.heightAnchor.constraint( + lessThanOrEqualTo: view.heightAnchor, + constant: Constants.Spring.overflowHeight - view.safeAreaInsets.top - Constants.minimumTopExpandedPadding) + constraints = [preferredHeightConstraint, maxHeightConstraint] + } else { + // Fill view bounds, respecting the given height fraction + constraints = [ + bottomSheetView.heightAnchor.constraint( + equalTo: view.heightAnchor, + multiplier: expandedHeightFraction, + constant: Constants.Spring.overflowHeight - view.safeAreaInsets.top - Constants.minimumTopExpandedPadding)] + } + return constraints + } + + // The height doesn't change while panning. The sheet only gets pulled out from the off-screen area. + private lazy var bottomSheetHeightConstraints: [NSLayoutConstraint] = generateBottomSheetHeightConstraints() + + private lazy var bottomSheetOffsetConstraint: NSLayoutConstraint = + bottomSheetView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: -collapsedOffsetFromBottom) + + private let contentViewController: UIViewController? + + private let contentView: UIView? + + private lazy var panGestureRecognizer: UIPanGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) + + private var translationAnimator: UIViewPropertyAnimator? + + private var needsExpandedOffsetUpdate: Bool = false + + private var currentOffsetFromBottom: CGFloat { + -bottomSheetOffsetConstraint.constant + } + + private var collapsedOffsetFromBottom: CGFloat { + collapsedContentHeight + (isExpandable ? ResizingHandleView.height : 0.0) + } + + private var expandedOffsetFromBottom: CGFloat { + return bottomSheetView.frame.height - Constants.Spring.overflowHeight + } + + private struct Constants { + // Maximum offset beyond the normal bounds with additional resistance + static let maxRubberBandOffset: CGFloat = 20.0 + static let minRubberBandScaleFactor: CGFloat = 0.05 + + // Swipes over this velocity ignore proximity to the collapsed / expanded offset and fly towards + // the offset that makes sense given the swipe direction + static let directionOverrideVelocityThreshold: CGFloat = 150 + + // Minimum padding from top when the sheet is fully expanded + static let minimumTopExpandedPadding: CGFloat = 25.0 + static let defaultCollapsedContentHeight: CGFloat = 75 + + static let cornerRadius: CGFloat = 14 + + struct Spring { + // Spring used in slow swipes - no oscillation + static let defaultDampingRatio: CGFloat = 1.0 + + // Spring used in fast swipes - slight oscillation + static let oscillatingDampingRatio: CGFloat = 0.8 + + // Swipes over this velocity get slight spring oscillation + static let flickVelocityThreshold: CGFloat = 800 + + static let maxInitialVelocity: CGFloat = 40.0 + static let animationDuration: TimeInterval = 0.4 + + // Off-screen overflow that can be partially revealed during spring oscillation or rubber banding (dragging the sheet beyond limits) + static let overflowHeight: CGFloat = 50.0 + } + + struct Shadow { + static let color: CGColor = UIColor.black.cgColor + static let opacity: Float = 0.14 + static let radius: CGFloat = 8 + static let offset: CGSize = CGSize(width: 0, height: 4) + } + } +} + +extension BottomSheetController: UIGestureRecognizerDelegate { + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return gestureRecognizer == panGestureRecognizer && otherGestureRecognizer == hostedScrollView?.panGestureRecognizer + } + + public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let scrollView = hostedScrollView, let panGesture = gestureRecognizer as? UIPanGestureRecognizer else { + return true + } + var shouldBegin = true + let fullyExpanded = currentOffsetFromBottom >= expandedOffsetFromBottom + + if fullyExpanded { + let scrolledToTop = scrollView.contentOffset.y <= 0 + let panningDown = panGesture.velocity(in: view).y > 0 + shouldBegin = scrolledToTop && panningDown + } + + return shouldBegin + } +} + +enum BottomSheetExpansionState { + case expanded + case collapsed +} diff --git a/ios/FluentUI/Bottom Sheet/BottomSheetPassthroughView.swift b/ios/FluentUI/Bottom Sheet/BottomSheetPassthroughView.swift new file mode 100644 index 000000000..6b51a0700 --- /dev/null +++ b/ios/FluentUI/Bottom Sheet/BottomSheetPassthroughView.swift @@ -0,0 +1,13 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +class BottomSheetPassthroughView: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let view = super.hitTest(point, with: event) + return view == self ? nil : view + } +} diff --git a/ios/FluentUI/Resources/Localization/en.lproj/Localizable.strings b/ios/FluentUI/Resources/Localization/en.lproj/Localizable.strings index f8b4abf73..920129aea 100644 --- a/ios/FluentUI/Resources/Localization/en.lproj/Localizable.strings +++ b/ios/FluentUI/Resources/Localization/en.lproj/Localizable.strings @@ -120,6 +120,9 @@ /* Format string for tab bar item accessbility labels. Format: ", <BadgeValue> items". Example: "Home, 5 items" */ "Accessibility.TabBarItemView.LabelFormat" = "%@, %@ items"; +/* Commanding Bottom Bar - More button */ +"CommandingBottomBar.More" = "More"; + /* Generic label for cancel action */ "Common.Cancel" = "Cancel"; diff --git a/ios/FluentUI/Tab Bar/TabBarItemView.swift b/ios/FluentUI/Tab Bar/TabBarItemView.swift index 4b2e61723..2f69853b0 100644 --- a/ios/FluentUI/Tab Bar/TabBarItemView.swift +++ b/ios/FluentUI/Tab Bar/TabBarItemView.swift @@ -8,6 +8,14 @@ import UIKit class TabBarItemView: UIView { let item: TabBarItem + var isEnabled: Bool = true { + didSet { + titleLabel.isEnabled = isEnabled + imageView.tintAdjustmentMode = isEnabled ? .automatic : .dimmed + isUserInteractionEnabled = isEnabled + } + } + var isSelected: Bool = false { didSet { titleLabel.isHighlighted = isSelected