blob: dbb40bd183482d4bd07e88b0c7a27af1ffa146b5 [file] [edit]
// Copyright 2015-present the Material Components for iOS authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import UIKit
import CatalogByConvention
import MaterialCatalog
import MaterialComponents.MaterialFlexibleHeader
import MaterialComponents.MaterialLibraryInfo
import MaterialComponents.MaterialRipple
import MaterialComponents.MaterialShadowElevations
import MaterialComponents.MaterialShadowLayer
import MaterialComponents.MaterialThemes
import MaterialComponents.MaterialTypography
import MaterialComponents.MaterialIcons_ic_arrow_back
class MDCCatalogComponentsController: UICollectionViewController,
UICollectionViewDelegateFlowLayout, MDCRippleTouchControllerDelegate
{
fileprivate struct Constants {
static let headerScrollThreshold: CGFloat = 30
static let inset: CGFloat = 16
static let menuTopVerticalSpacing: CGFloat = 38
static let logoWidthHeight: CGFloat = 30
static let menuButtonWidthHeight: CGFloat = 24
static let spacing: CGFloat = 1
}
fileprivate lazy var headerViewController = MDCFlexibleHeaderViewController()
private lazy var rippleController: MDCRippleTouchController = {
let controller = MDCRippleTouchController()
controller.delegate = self
controller.shouldProcessRippleWithScrollViewGestures = false
return controller
}()
private lazy var logo: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
return imageView
}()
private lazy var menuButton: UIButton = {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
let dotsImage = MDCIcons.imageFor_ic_more_horiz()?.withRenderingMode(.alwaysTemplate)
button.setImage(dotsImage, for: .normal)
button.adjustsImageWhenHighlighted = false
button.accessibilityLabel = "Menu"
button.accessibilityHint = "Opens catalog configuration options."
// Without this compiler version check, the build fails on Xcode versions <11.4 with the error:
// "Use of undeclared type 'UIPointerInteractionDelegate'"
// Ideally, we would be able to tie this to an iOS version rather than a compiler version, but
// such a solution does not seem to be available for Swift.
#if compiler(>=5.2)
if #available(iOS 13.4, *) {
let interaction = UIPointerInteraction(delegate: self)
button.addInteraction(interaction)
}
#endif
return button
}()
private let node: CBCNode
private lazy var titleLabel: UILabel = {
let titleLabel = UILabel()
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.adjustsFontSizeToFitWidth = true
return titleLabel
}()
private var logoLeftPaddingConstraint: NSLayoutConstraint?
private var menuButtonRightPaddingConstraint: NSLayoutConstraint?
private var menuTopPaddingConstraint: NSLayoutConstraint?
init(collectionViewLayout ignoredLayout: UICollectionViewLayout, node: CBCNode) {
self.node = node
let layout = UICollectionViewFlowLayout()
let sectionInset: CGFloat = Constants.spacing
layout.sectionInset = UIEdgeInsets(
top: sectionInset,
left: sectionInset,
bottom: sectionInset,
right: sectionInset)
layout.minimumInteritemSpacing = Constants.spacing
layout.minimumLineSpacing = Constants.spacing
super.init(collectionViewLayout: layout)
title = "Material Components for iOS"
addChild(headerViewController)
headerViewController.isTopLayoutGuideAdjustmentEnabled = true
headerViewController.inferTopSafeAreaInsetFromViewController = true
headerViewController.headerView.minMaxHeightIncludesSafeArea = false
headerViewController.headerView.maximumHeight = 128
headerViewController.headerView.minimumHeight = 56
collectionView?.register(
MDCCatalogCollectionViewCell.self,
forCellWithReuseIdentifier: "MDCCatalogCollectionViewCell")
collectionView?.backgroundColor = AppTheme.containerScheme.colorScheme.backgroundColor
MDCIcons.ic_arrow_backUseNewStyle(true)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.themeDidChange),
name: AppTheme.didChangeGlobalThemeNotificationName,
object: nil)
}
@objc func themeDidChange(notification: NSNotification) {
let colorScheme = AppTheme.containerScheme.colorScheme
headerViewController.headerView.backgroundColor = colorScheme.primaryColor
setNeedsStatusBarAppearanceUpdate()
titleLabel.textColor = colorScheme.onPrimaryColor
menuButton.tintColor = colorScheme.onPrimaryColor
collectionView?.collectionViewLayout.invalidateLayout()
collectionView?.reloadData()
}
convenience init(node: CBCNode) {
self.init(collectionViewLayout: UICollectionViewLayout(), node: node)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
rippleController.addRipple(to: self.collectionView!)
let containerView = UIView(frame: headerViewController.headerView.bounds)
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
titleLabel.text = title!
titleLabel.textColor = AppTheme.containerScheme.colorScheme.onPrimaryColor
titleLabel.textAlignment = .center
titleLabel.font = AppTheme.containerScheme.typographyScheme.headline6
titleLabel.sizeToFit()
let titleInsets = UIEdgeInsets(
top: 0,
left: Constants.inset,
bottom: Constants.inset,
right: Constants.inset)
let titleSize = titleLabel.sizeThatFits(containerView.bounds.size)
containerView.addSubview(titleLabel)
headerViewController.headerView.addSubview(containerView)
headerViewController.headerView.forwardTouchEvents(for: containerView)
containerView.addSubview(logo)
let colorScheme = AppTheme.containerScheme.colorScheme
let image = MDCDrawImage(
CGRect(
x: 0,
y: 0,
width: Constants.logoWidthHeight,
height: Constants.logoWidthHeight),
{ MDCCatalogDrawMDCLogoLight($0, $1) },
colorScheme)
logo.image = image
menuButton.addTarget(
self.navigationController,
action: #selector(navigationController?.presentMenu),
for: .touchUpInside)
menuButton.tintColor = colorScheme.onPrimaryColor
containerView.addSubview(menuButton)
setupFlexibleHeaderContentConstraints()
constrainLabel(
label: titleLabel,
containerView: containerView,
insets: titleInsets,
height: titleSize.height)
headerViewController.headerView.backgroundColor = colorScheme.primaryColor
headerViewController.headerView.trackingScrollView = collectionView
headerViewController.headerView.setShadowLayer(MDCShadowLayer()) { (layer, intensity) in
let shadowLayer = layer as? MDCShadowLayer
CATransaction.begin()
CATransaction.setDisableActions(true)
shadowLayer!.elevation = ShadowElevation(intensity * ShadowElevation.appBar.rawValue)
CATransaction.commit()
}
view.addSubview(headerViewController.view)
headerViewController.didMove(toParent: self)
collectionView?.accessibilityIdentifier = "componentList"
if #available(iOS 11.0, *) {
collectionView?.contentInsetAdjustmentBehavior = .always
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
collectionView?.collectionViewLayout.invalidateLayout()
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func willAnimateRotation(
to toInterfaceOrientation: UIInterfaceOrientation, duration: TimeInterval
) {
collectionView?.collectionViewLayout.invalidateLayout()
}
override var childForStatusBarStyle: UIViewController? {
return headerViewController
}
override var childForStatusBarHidden: UIViewController? {
return headerViewController
}
@available(iOS 11, *)
override func viewSafeAreaInsetsDidChange() {
// Re-constraint the title label to account for changes in safeAreaInsets's left and right.
logoLeftPaddingConstraint?.constant = Constants.inset + view.safeAreaInsets.left
menuButtonRightPaddingConstraint?.constant = -1 * (Constants.inset + view.safeAreaInsets.right)
menuTopPaddingConstraint?.constant = Constants.inset + view.safeAreaInsets.top
}
func setupFlexibleHeaderContentConstraints() {
logoLeftPaddingConstraint = NSLayoutConstraint(
item: logo,
attribute: .leading,
relatedBy: .equal,
toItem: logo.superview,
attribute: .leading,
multiplier: 1,
constant: Constants.inset)
logoLeftPaddingConstraint?.isActive = true
menuButtonRightPaddingConstraint = NSLayoutConstraint(
item: menuButton,
attribute: .trailing,
relatedBy: .equal,
toItem: menuButton.superview,
attribute: .trailing,
multiplier: 1,
constant: -1 * Constants.inset)
menuButtonRightPaddingConstraint?.isActive = true
menuTopPaddingConstraint = NSLayoutConstraint(
item: menuButton,
attribute: .top,
relatedBy: .equal,
toItem: menuButton.superview,
attribute: .top,
multiplier: 1,
constant: Constants.menuTopVerticalSpacing)
menuTopPaddingConstraint?.isActive = true
NSLayoutConstraint(
item: logo,
attribute: .centerY,
relatedBy: .equal,
toItem: menuButton,
attribute: .centerY,
multiplier: 1,
constant: 0
).isActive = true
NSLayoutConstraint(
item: logo,
attribute: .width,
relatedBy: .equal,
toItem: logo,
attribute: .height,
multiplier: 1,
constant: 0
).isActive = true
NSLayoutConstraint(
item: logo,
attribute: .width,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1,
constant: Constants.logoWidthHeight
).isActive = true
NSLayoutConstraint(
item: menuButton,
attribute: .width,
relatedBy: .equal,
toItem: menuButton,
attribute: .height,
multiplier: 1,
constant: 0
).isActive = true
NSLayoutConstraint(
item: menuButton,
attribute: .width,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1,
constant: Constants.menuButtonWidthHeight
).isActive = true
}
// MARK: UICollectionViewDataSource
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection section: Int
) -> Int {
return node.children.count
}
// MARK: MDCRippleTouchControllerDelegate
func rippleTouchController(
_ rippleTouchController: MDCRippleTouchController,
shouldProcessRippleTouchesAtTouchLocation location: CGPoint
) -> Bool {
return self.collectionView?.indexPathForItem(at: location) != nil
}
func rippleTouchController(
_ rippleTouchController: MDCRippleTouchController,
rippleViewAtTouchLocation location: CGPoint
) -> MDCRippleView? {
if let indexPath = self.collectionView?.indexPathForItem(at: location) {
let cell = self.collectionView?.cellForItem(at: indexPath)
return MDCRippleView.injectedRippleView(for: cell!)
}
return MDCRippleView()
}
// MARK: UICollectionViewDelegate
override func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell =
collectionView.dequeueReusableCell(
withReuseIdentifier: "MDCCatalogCollectionViewCell",
for: indexPath)
cell.backgroundColor = AppTheme.containerScheme.colorScheme.backgroundColor
let componentName = node.children[indexPath.row].title
if let catalogCell = cell as? MDCCatalogCollectionViewCell {
catalogCell.populateView(componentName)
}
// Ensure that ripple animations aren't recycled.
MDCRippleView.injectedRippleView(for: view).cancelAllRipples(animated: false, completion: nil)
return cell
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let dividerWidth: CGFloat = 1
var safeInsets: CGFloat = 0
if #available(iOS 11, *) {
safeInsets = view.safeAreaInsets.left + view.safeAreaInsets.right
}
var cellWidthHeight: CGFloat
// iPhones have 2 columns in portrait and 3 in landscape
if UI_USER_INTERFACE_IDIOM() == .phone {
cellWidthHeight = (view.frame.size.width - 3 * dividerWidth - safeInsets) / 2
if view.frame.size.width > view.frame.size.height {
cellWidthHeight = (view.frame.size.width - 4 * dividerWidth - safeInsets) / 3
}
} else {
// iPads have 4 columns
cellWidthHeight = (view.frame.size.width - 5 * dividerWidth - safeInsets) / 4
}
return CGSize(width: cellWidthHeight, height: cellWidthHeight)
}
override func collectionView(
_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath
) {
let node = self.node.children[indexPath.row]
var vc: UIViewController
if node.isExample() {
vc = node.createExampleViewController()
} else {
vc = MDCNodeListViewController(node: node)
}
self.navigationController?.setMenuBarButton(for: vc)
self.navigationController?.pushViewController(vc, animated: true)
}
// MARK: Private
func constrainLabel(
label: UILabel,
containerView: UIView,
insets: UIEdgeInsets,
height: CGFloat
) {
NSLayoutConstraint(
item: label,
attribute: .leading,
relatedBy: .equal,
toItem: logo,
attribute: .trailing,
multiplier: 1.0,
constant: insets.left
).isActive = true
NSLayoutConstraint(
item: label,
attribute: .trailing,
relatedBy: .equal,
toItem: menuButton,
attribute: .leading,
multiplier: 1.0,
constant: -insets.right
).isActive = true
NSLayoutConstraint(
item: label,
attribute: .bottom,
relatedBy: .equal,
toItem: containerView,
attribute: .bottom,
multiplier: 1.0,
constant: -insets.bottom
).isActive = true
NSLayoutConstraint(
item: label,
attribute: .height,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1.0,
constant: height
).isActive = true
}
}
// Without this compiler version check, the build fails on Xcode versions <11.4 with the error:
// "Use of undeclared type 'UIPointerInteractionDelegate'"
// Ideally, we would be able to tie this to an iOS version rather than a compiler version, but such
// a solution does not seem to be available for Swift.
#if compiler(>=5.2)
@available(iOS 13.4, *)
extension MDCCatalogComponentsController: UIPointerInteractionDelegate {
@available(iOS 13.4, *)
func pointerInteraction(
_ interaction: UIPointerInteraction,
styleFor region: UIPointerRegion
) -> UIPointerStyle? {
guard let interactionView = interaction.view else {
return nil
}
let targetedPreview = UITargetedPreview(view: interactionView)
let pointerStyle = UIPointerStyle(effect: .highlight(targetedPreview))
return pointerStyle
}
}
#endif
// UIScrollViewDelegate
extension MDCCatalogComponentsController {
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == headerViewController.headerView.trackingScrollView {
self.headerViewController.headerView.trackingScrollDidScroll()
}
}
override func scrollViewDidEndDragging(
_ scrollView: UIScrollView,
willDecelerate decelerate: Bool
) {
let headerView = headerViewController.headerView
if scrollView == headerView.trackingScrollView {
headerView.trackingScrollDidEndDraggingWillDecelerate(decelerate)
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if scrollView == headerViewController.headerView.trackingScrollView {
self.headerViewController.headerView.trackingScrollDidEndDecelerating()
}
}
override func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
let headerView = headerViewController.headerView
if scrollView == headerView.trackingScrollView {
headerView.trackingScrollWillEndDragging(
withVelocity: velocity,
targetContentOffset: targetContentOffset)
}
}
}