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)
This commit is contained in:
Lukas Capkovic 2021-05-18 17:32:10 -07:00 коммит произвёл GitHub
Родитель c360c0faeb
Коммит 83cf151905
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
14 изменённых файлов: 1722 добавлений и 0 удалений

Просмотреть файл

@ -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'

Просмотреть файл

@ -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 = "<group>"; };
7D23482924D89C1C00FBE057 /* AvatarGroupViewDemoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarGroupViewDemoController.swift; sourceTree = "<group>"; };
7DC2FB2A24C0F4FD00367A55 /* TableViewCellFileAccessoryViewDemoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableViewCellFileAccessoryViewDemoController.swift; sourceTree = "<group>"; };
80AECC0B2630F1BB005AF2F3 /* BottomCommandingDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomCommandingDemoController.swift; sourceTree = "<group>"; };
80B1F7002628D8BB004DFEE5 /* BottomSheetDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetDemoController.swift; sourceTree = "<group>"; };
8AF03E1F24B6BE3100E6E2A2 /* ContactCollectionViewDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCollectionViewDemoController.swift; sourceTree = "<group>"; };
A589F855211BA71000471C23 /* LabelDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelDemoController.swift; sourceTree = "<group>"; };
A591A3F320F429EB001ED23B /* Demos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demos.swift; sourceTree = "<group>"; };
@ -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 */,

Просмотреть файл

@ -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),

Просмотреть файл

@ -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..<newCount])
}
}
@objc private func decrementHeroCommands() {
let currentCount = bottomCommandingController?.heroItems.count ?? 0
if currentCount > 1 {
let newCount = currentCount - 1
bottomCommandingController?.heroItems = Array(heroItems[0..<newCount])
}
}
@objc private func commandAction(item: CommandingItem) {
if heroItems.contains(item) {
showMessage("Hero command tapped")
} else if expandedListSections.contains(where: { $0.items.contains(item) }) {
showMessage("Expanded list command tapped")
}
}
private func showMessage(_ message: String) {
let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
private lazy var incrementHeroCommandCountButton: Button = {
let button = Button()
button.image = UIImage(named: "ic_fluent_add_20_regular")
button.accessibilityLabel = "Increment hero command count"
button.addTarget(self, action: #selector(incrementHeroCommands), for: .touchUpInside)
return button
}()
private lazy var decrementHeroCommandCountButton: Button = {
let button = Button()
button.image = UIImage(named: "ic_fluent_subtract_20_regular")
button.accessibilityLabel = "Decrement hero command count"
button.addTarget(self, action: #selector(decrementHeroCommands), for: .touchUpInside)
return button
}()
private let homeImage = UIImage(named: "Home_24")!
private let homeSelectedImage = UIImage(named: "Home_Selected_24")!
private let boldImage = UIImage(named: "textBold24Regular")!
private var heroIconChanged: Bool = false
private var listIconChanged: Bool = false
private var bottomCommandingController: BottomCommandingController?
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
}
}
extension BottomCommandingDemoController: UITableViewDelegate {
func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> 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()
}
}

Просмотреть файл

@ -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()
}
}

Просмотреть файл

@ -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 = "<group>"; };
7DC2FB2724C0ED1100367A55 /* TableViewCellFileAccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellFileAccessoryView.swift; sourceTree = "<group>"; };
7DC2FB2C24D209E300367A55 /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; };
8035CAAA2633A442007B3FD1 /* BottomCommandingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomCommandingController.swift; sourceTree = "<group>"; };
8035CACA26377C14007B3FD1 /* CommandingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandingItem.swift; sourceTree = "<group>"; };
8035CADC2638E435007B3FD1 /* CommandingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandingSection.swift; sourceTree = "<group>"; };
80AECBD82629F18E005AF2F3 /* BottomSheetController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomSheetController.swift; sourceTree = "<group>"; };
80AECBF1262FC34E005AF2F3 /* BottomSheetPassthroughView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetPassthroughView.swift; sourceTree = "<group>"; };
86AF4F7425AFC746005D4253 /* PillButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillButtonStyle.swift; sourceTree = "<group>"; };
8A01C86E248FFC5300C971F3 /* ContactView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactView.swift; sourceTree = "<group>"; };
8AF03E1924B6BD4700E6E2A2 /* ContactCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCollectionView.swift; sourceTree = "<group>"; };
@ -712,6 +727,25 @@
path = xcode;
sourceTree = "<group>";
};
80B1F6F52628CDEB004DFEE5 /* Bottom Sheet */ = {
isa = PBXGroup;
children = (
80AECBD82629F18E005AF2F3 /* BottomSheetController.swift */,
80AECBF1262FC34E005AF2F3 /* BottomSheetPassthroughView.swift */,
);
path = "Bottom Sheet";
sourceTree = "<group>";
};
80B52538264CA5BC00E3FD32 /* Bottom Commanding */ = {
isa = PBXGroup;
children = (
8035CAAA2633A442007B3FD1 /* BottomCommandingController.swift */,
8035CACA26377C14007B3FD1 /* CommandingItem.swift */,
8035CADC2638E435007B3FD1 /* CommandingSection.swift */,
);
path = "Bottom Commanding";
sourceTree = "<group>";
};
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 */,

Просмотреть файл

@ -0,0 +1 @@
more-24x24.imageset

Просмотреть файл

@ -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
}
}
}

Просмотреть файл

@ -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)
}

Просмотреть файл

@ -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
}
}

Просмотреть файл

@ -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
}

Просмотреть файл

@ -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
}
}

Просмотреть файл

@ -120,6 +120,9 @@
/* Format string for tab bar item accessbility labels. Format: "<Title>, <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";

Просмотреть файл

@ -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