/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import Foundation import Shared import SDWebImage import Storage struct TopSiteCellUX { static let TitleHeight: CGFloat = 20 static let TitleBackgroundColor = UIColor(colorLiteralRed: 1, green: 1, blue: 1, alpha: 0.7) static let TitleTextColor = UIColor.black static let TitleFont = DynamicFontHelper.defaultHelper.SmallSizeRegularWeightAS static let SelectedOverlayColor = UIColor(white: 0.0, alpha: 0.25) static let CellCornerRadius: CGFloat = 4 static let TitleOffset: CGFloat = 5 static let OverlayColor = UIColor(white: 0.0, alpha: 0.25) static let IconSizePercent: CGFloat = 0.8 static let BorderColor = UIColor(white: 0, alpha: 0.1) static let BorderWidth: CGFloat = 0.5 static let PinIconSize: CGFloat = 12 static let PinColor = UIColor(rgb: 0x272727) } /* * The TopSite cell that appears in the ASHorizontalScrollView. */ class TopSiteItemCell: UICollectionViewCell { var url: URL? lazy var imageView: UIImageView = { let imageView = UIImageView() imageView.layer.masksToBounds = true return imageView }() lazy var pinImageView: UIImageView = { let imageView = UIImageView() imageView.image = UIImage.templateImageNamed("pin_small") imageView.tintColor = TopSiteCellUX.PinColor return imageView }() lazy fileprivate var titleLabel: UILabel = { let titleLabel = UILabel() titleLabel.layer.masksToBounds = true titleLabel.textAlignment = .center titleLabel.font = TopSiteCellUX.TitleFont titleLabel.textColor = TopSiteCellUX.TitleTextColor titleLabel.backgroundColor = UIColor.clear return titleLabel }() lazy private var faviconBG: UIView = { let view = UIView() view.layer.cornerRadius = TopSiteCellUX.CellCornerRadius view.layer.masksToBounds = true view.layer.borderWidth = TopSiteCellUX.BorderWidth view.layer.borderColor = TopSiteCellUX.BorderColor.cgColor return view }() lazy var selectedOverlay: UIView = { let selectedOverlay = UIView() selectedOverlay.backgroundColor = TopSiteCellUX.OverlayColor selectedOverlay.isHidden = true return selectedOverlay }() lazy var titleBorder: CALayer = { let border = CALayer() border.backgroundColor = TopSiteCellUX.BorderColor.cgColor return border }() override var isSelected: Bool { didSet { self.selectedOverlay.isHidden = !isSelected } } override init(frame: CGRect) { super.init(frame: frame) isAccessibilityElement = true accessibilityIdentifier = "TopSite" contentView.addSubview(titleLabel) contentView.addSubview(faviconBG) contentView.addSubview(imageView) contentView.addSubview(selectedOverlay) titleLabel.snp.makeConstraints { make in make.left.equalTo(self).offset(TopSiteCellUX.TitleOffset) make.right.equalTo(self).offset(-TopSiteCellUX.TitleOffset) make.height.equalTo(TopSiteCellUX.TitleHeight) make.bottom.equalTo(self) } imageView.snp.makeConstraints { make in make.size.equalTo(floor(frame.width * TopSiteCellUX.IconSizePercent)) make.centerX.equalTo(self) make.centerY.equalTo(self).inset(-TopSiteCellUX.TitleHeight/2) } selectedOverlay.snp.makeConstraints { make in make.edges.equalTo(contentView) } faviconBG.snp.makeConstraints { make in make.top.left.right.equalTo(self) make.bottom.equalTo(self).inset(TopSiteCellUX.TitleHeight) } } override func layoutSubviews() { super.layoutSubviews() titleBorder.frame = CGRect(x: 0, y: frame.height - TopSiteCellUX.TitleHeight - TopSiteCellUX.BorderWidth, width: frame.width, height: TopSiteCellUX.BorderWidth) imageView.snp.remakeConstraints { make in make.size.equalTo(floor(self.frame.width * TopSiteCellUX.IconSizePercent)) make.centerX.equalTo(self) make.centerY.equalTo(self).inset(-TopSiteCellUX.TitleHeight/2) } } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() contentView.backgroundColor = UIColor.clear imageView.image = nil imageView.backgroundColor = UIColor.clear faviconBG.backgroundColor = UIColor.clear pinImageView.removeFromSuperview() imageView.sd_cancelCurrentImageLoad() titleLabel.text = "" titleLabel.snp.updateConstraints { make in make.left.equalTo(self).offset(TopSiteCellUX.TitleOffset) } } func configureWithTopSiteItem(_ site: Site) { url = site.tileURL if let provider = site.metadata?.providerName { titleLabel.text = provider.lowercased() } else { titleLabel.text = site.tileURL.hostSLD } // If its a pinned site add a bullet point to the front if let _ = site as? PinnedSite { contentView.addSubview(pinImageView) pinImageView.snp.makeConstraints { make in make.right.equalTo(self.titleLabel.snp.left) make.size.equalTo(TopSiteCellUX.PinIconSize) make.centerY.equalTo(self.titleLabel.snp.centerY) } titleLabel.snp.updateConstraints { make in make.left.equalTo(self).offset(TopSiteCellUX.PinIconSize) } } accessibilityLabel = titleLabel.text imageView.setFavicon(forSite: site, onCompletion: { [weak self] (color, url) in if let url = url, url == self?.url { self?.faviconBG.backgroundColor = color self?.imageView.backgroundColor = color } }) } } // An empty cell to show when a row is incomplete class EmptyTopsiteDecorationCell: UICollectionReusableView { override init(frame: CGRect) { super.init(frame: frame) self.layer.cornerRadius = TopSiteCellUX.CellCornerRadius self.layer.borderWidth = TopSiteCellUX.BorderWidth self.layer.borderColor = TopSiteCellUX.BorderColor.cgColor } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } struct ASHorizontalScrollCellUX { static let TopSiteCellIdentifier = "TopSiteItemCell" static let TopSiteEmptyCellIdentifier = "TopSiteItemEmptyCell" static let TopSiteItemSize = CGSize(width: 75, height: 75) static let BackgroundColor = UIColor.white static let PageControlRadius: CGFloat = 3 static let PageControlSize = CGSize(width: 30, height: 15) static let PageControlOffset: CGFloat = 12 static let MinimumInsets: CGFloat = 14 } /* The View that describes the topSite cell that appears in the tableView. */ class ASHorizontalScrollCell: UICollectionViewCell { lazy var collectionView: UICollectionView = { let layout = HorizontalFlowLayout() layout.itemSize = ASHorizontalScrollCellUX.TopSiteItemSize let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout) collectionView.register(TopSiteItemCell.self, forCellWithReuseIdentifier: ASHorizontalScrollCellUX.TopSiteCellIdentifier) collectionView.backgroundColor = UIColor.clear collectionView.showsHorizontalScrollIndicator = false collectionView.isPagingEnabled = true return collectionView }() lazy fileprivate var pageControl: FilledPageControl = { let pageControl = FilledPageControl() pageControl.tintColor = UIColor.gray pageControl.indicatorRadius = ASHorizontalScrollCellUX.PageControlRadius pageControl.isUserInteractionEnabled = true pageControl.isAccessibilityElement = true pageControl.accessibilityIdentifier = "pageControl" pageControl.accessibilityLabel = Strings.ASPageControlButton pageControl.accessibilityTraits = UIAccessibilityTraitButton return pageControl }() lazy fileprivate var pageControlPress: UITapGestureRecognizer = { let press = UITapGestureRecognizer(target: self, action: #selector(ASHorizontalScrollCell.handlePageTap(_:))) // press.delegate = self return press }() weak var delegate: ASHorizontalScrollCellManager? { didSet { collectionView.delegate = delegate collectionView.dataSource = delegate delegate?.pageChangedHandler = { [weak self] progress in self?.currentPageChanged(progress) } } } override init(frame: CGRect) { super.init(frame: frame) isAccessibilityElement = false accessibilityIdentifier = "TopSitesCell" backgroundColor = UIColor.clear contentView.addSubview(collectionView) contentView.addSubview(pageControl) pageControl.addGestureRecognizer(self.pageControlPress) collectionView.snp.makeConstraints { make in make.edges.equalTo(contentView) } pageControl.snp.makeConstraints { make in make.size.equalTo(ASHorizontalScrollCellUX.PageControlSize) make.top.equalTo(collectionView.snp.bottom).inset(ASHorizontalScrollCellUX.PageControlOffset) make.centerX.equalTo(self.snp.centerX) } } override func layoutSubviews() { super.layoutSubviews() let layout = collectionView.collectionViewLayout as! HorizontalFlowLayout pageControl.pageCount = layout.numberOfPages(with: self.frame.size) pageControl.isHidden = pageControl.pageCount <= 1 } func currentPageChanged(_ currentPage: CGFloat) { pageControl.progress = currentPage if currentPage == floor(currentPage) { UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil) self.setNeedsLayout() } } func handlePageTap(_ gesture: UITapGestureRecognizer) { guard pageControl.pageCount > 1 else { return } if pageControl.pageCount > pageControl.currentPage + 1 { pageControl.progress = CGFloat(pageControl.currentPage + 1) } else { pageControl.progress = CGFloat(pageControl.currentPage - 1) } let swipeCoordinate = CGFloat(pageControl.currentPage) * self.collectionView.frame.size.width self.collectionView.setContentOffset(CGPoint(x: swipeCoordinate, y: 0), animated: true) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /* A custom layout used to show a horizontal scrolling list with paging. Similar to iOS springboard. A modified version of http://stackoverflow.com/a/34167915 */ class HorizontalFlowLayout: UICollectionViewLayout { fileprivate var cellCount: Int { if let collectionView = collectionView, let dataSource = collectionView.dataSource { return dataSource.collectionView(collectionView, numberOfItemsInSection: 0) } return 0 } var boundsSize = CGSize.zero private var insets = UIEdgeInsets(equalInset: ASHorizontalScrollCellUX.MinimumInsets) private var sectionInsets: CGFloat = 0 var itemSize = CGSize.zero var cachedAttributes: [UICollectionViewLayoutAttributes]? override func prepare() { super.prepare() if boundsSize != self.collectionView?.frame.size { self.collectionView?.setContentOffset(CGPoint.zero, animated: false) } boundsSize = self.collectionView?.frame.size ?? CGSize.zero cachedAttributes = nil register(EmptyTopsiteDecorationCell.self, forDecorationViewOfKind: ASHorizontalScrollCellUX.TopSiteEmptyCellIdentifier) } func numberOfPages(with bounds: CGSize) -> Int { let itemsPerPage = maxVerticalItemsCount(height: bounds.height) * maxHorizontalItemsCount(width: bounds.width) // Sometimes itemsPerPage is 0. In this case just return 0. We dont want to try dividing by 0. return itemsPerPage == 0 ? 0 : Int(ceil(Double(cellCount) / Double(itemsPerPage))) } func calculateLayout(for size: CGSize) -> (size: CGSize, cellSize: CGSize, cellInsets: UIEdgeInsets) { let width = size.width let height = size.height guard width != 0 else { return (size: CGSize.zero, cellSize: self.itemSize, cellInsets: self.insets) } let horizontalItemsCount = maxHorizontalItemsCount(width: width) var verticalItemsCount = maxVerticalItemsCount(height: height) if cellCount <= horizontalItemsCount { // If we have only a few items don't provide space for multiple rows. verticalItemsCount = 1 } // Take the number of cells and subtract its space in the view from the height. The left over space is the white space. // The left over space is then devided evenly into (n + 1) parts to figure out how much space should be inbetween a cell var verticalInsets = floor((height - (CGFloat(verticalItemsCount) * itemSize.height)) / CGFloat(verticalItemsCount + 1)) var horizontalInsets = floor((width - (CGFloat(horizontalItemsCount) * itemSize.width)) / CGFloat(horizontalItemsCount + 1)) // We want a minimum inset to make things not look crowded. We also don't want uneven spacing. // If we dont have this. Set a minimum inset and recalculate the size of a cell var estimatedItemSize = itemSize if horizontalInsets != ASHorizontalScrollCellUX.MinimumInsets { verticalInsets = ASHorizontalScrollCellUX.MinimumInsets horizontalInsets = ASHorizontalScrollCellUX.MinimumInsets estimatedItemSize.width = floor((width - (CGFloat(horizontalItemsCount + 1) * horizontalInsets)) / CGFloat(horizontalItemsCount)) estimatedItemSize.height = estimatedItemSize.width + TopSiteCellUX.TitleHeight } //calculate our estimates. let estimatedHeight = floor(estimatedItemSize.height * CGFloat(verticalItemsCount)) + (verticalInsets * (CGFloat(verticalItemsCount) + 1)) let estimatedSize = CGSize(width: CGFloat(numberOfPages(with: boundsSize)) * width, height: estimatedHeight) let estimatedInsets = UIEdgeInsets(top: verticalInsets, left: horizontalInsets, bottom: verticalInsets, right: horizontalInsets) return (size: estimatedSize, cellSize: estimatedItemSize, cellInsets: estimatedInsets) } override var collectionViewContentSize: CGSize { let estimatedLayout = calculateLayout(for: boundsSize) insets = estimatedLayout.cellInsets itemSize = estimatedLayout.cellSize boundsSize.height = estimatedLayout.size.height return estimatedLayout.size } func maxVerticalItemsCount(height: CGFloat) -> Int { let verticalItemsCount = Int(floor(height / (ASHorizontalScrollCellUX.TopSiteItemSize.height + insets.top))) if let delegate = self.collectionView?.delegate as? ASHorizontalLayoutDelegate { return delegate.numberOfVerticalItems() } else { return verticalItemsCount } } func maxHorizontalItemsCount(width: CGFloat) -> Int { let horizontalItemsCount = Int(floor(width / (ASHorizontalScrollCellUX.TopSiteItemSize.width + insets.left))) if let delegate = self.collectionView?.delegate as? ASHorizontalLayoutDelegate { return delegate.numberOfHorizontalItems() } else { return horizontalItemsCount } } override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { let decorationAttr = UICollectionViewLayoutAttributes(forDecorationViewOfKind: elementKind, with: indexPath) let cellAttr = self.computeLayoutAttributesForCellAtIndexPath(indexPath) decorationAttr.frame = cellAttr.frame decorationAttr.frame.size.height -= TopSiteCellUX.TitleHeight decorationAttr.zIndex = -1 return decorationAttr } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { if cachedAttributes != nil { return cachedAttributes } var allAttributes = [UICollectionViewLayoutAttributes]() for i in 0 ..< cellCount { let indexPath = IndexPath(row: i, section: 0) let attr = self.computeLayoutAttributesForCellAtIndexPath(indexPath) allAttributes.append(attr) } //create decoration attributes let horizontalItemsCount = maxHorizontalItemsCount(width: boundsSize.width) var numberOfCells = cellCount while numberOfCells % horizontalItemsCount != 0 { //we need some empty cells dawg. let attr = self.layoutAttributesForDecorationView(ofKind: ASHorizontalScrollCellUX.TopSiteEmptyCellIdentifier, at: IndexPath(item: numberOfCells, section: 0)) allAttributes.append(attr!) numberOfCells += 1 } cachedAttributes = allAttributes return allAttributes } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return self.computeLayoutAttributesForCellAtIndexPath(indexPath) } override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { cachedAttributes = nil // Sometimes when the topsiteCell isnt on the screen the newbounds that it tries to layout in is 0 // Resulting in incorrect layouts. Only layout when a valid width is given return newBounds.width > 0 } func computeLayoutAttributesForCellAtIndexPath(_ indexPath: IndexPath) -> UICollectionViewLayoutAttributes { let row = indexPath.row let bounds = self.collectionView!.bounds let verticalItemsCount = maxVerticalItemsCount(height: bounds.size.height) let horizontalItemsCount = maxHorizontalItemsCount(width: bounds.size.width) let itemsPerPage = verticalItemsCount * horizontalItemsCount let columnPosition = row % horizontalItemsCount let rowPosition = (row / horizontalItemsCount) % verticalItemsCount let itemPage = Int(floor(Double(row)/Double(itemsPerPage))) let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath) var frame = CGRect.zero frame.origin.x = CGFloat(itemPage) * bounds.size.width + CGFloat(columnPosition) * (itemSize.width + insets.left) + insets.left frame.origin.y = CGFloat(rowPosition) * (itemSize.height + insets.top) + insets.top frame.size = itemSize attr.frame = frame return attr } } /* Defines the number of items to show in topsites for different size classes. */ struct ASTopSiteSourceUX { static let verticalItemsForTraitSizes = [UIUserInterfaceSizeClass.compact: 1, UIUserInterfaceSizeClass.regular: 2, UIUserInterfaceSizeClass.unspecified: 0] static let maxNumberOfPages = 2 static let CellIdentifier = "TopSiteItemCell" } protocol ASHorizontalLayoutDelegate { func numberOfVerticalItems() -> Int func numberOfHorizontalItems() -> Int } /* This Delegate/DataSource is used to manage the ASHorizontalScrollCell's UICollectionView. This is left generic enough for it to be re used for other parts of Activity Stream. */ class ASHorizontalScrollCellManager: NSObject, UICollectionViewDelegate, UICollectionViewDataSource, ASHorizontalLayoutDelegate { var content: [Site] = [] var urlPressedHandler: ((URL, IndexPath) -> Void)? var pageChangedHandler: ((CGFloat) -> Void)? // The current traits that define the parent ViewController. Used to determine how many rows/columns should be created. var currentTraits: UITraitCollection? // Size classes define how many items to show per row/column. func numberOfVerticalItems() -> Int { guard let traits = currentTraits else { return 0 } return ASTopSiteSourceUX.verticalItemsForTraitSizes[traits.verticalSizeClass]! } func numberOfHorizontalItems() -> Int { guard let traits = currentTraits else { return 0 } let isLandscape = UIInterfaceOrientationIsLandscape(UIApplication.shared.statusBarOrientation) if UIDevice.current.userInterfaceIdiom == .phone { if isLandscape { return 8 } else { return 4 } } // On iPad // The number of items in a row is equal to the number of highlights in a row * 2 var numItems: Int = Int(ASPanelUX.numberOfItemsPerRowForSizeClassIpad[traits.horizontalSizeClass]) if UIInterfaceOrientationIsPortrait(UIApplication.shared.statusBarOrientation) || (traits.horizontalSizeClass == .compact && isLandscape) { numItems = numItems - 1 } return numItems * 2 } func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return self.content.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ASTopSiteSourceUX.CellIdentifier, for: indexPath) as! TopSiteItemCell let contentItem = content[indexPath.row] cell.configureWithTopSiteItem(contentItem) return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let contentItem = content[indexPath.row] guard let url = contentItem.url.asURL else { return } urlPressedHandler?(url, indexPath) } func scrollViewDidScroll(_ scrollView: UIScrollView) { let pageWidth = scrollView.frame.width pageChangedHandler?(scrollView.contentOffset.x / pageWidth) } }