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:
Родитель
c360c0faeb
Коммит
83cf151905
|
@ -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
|
||||
|
|
Загрузка…
Ссылка в новой задаче