// // BaseButtonBarPagerTabStripViewController.swift // VLC-iOS // // Created by Carola Nitz on 5/3/18. // Copyright © 2018 VideoLAN. All rights reserved. // import Foundation class IconLabelCell: UICollectionViewCell { @IBOutlet weak var iconImage: UIImageView! @IBOutlet weak var iconLabel: UILabel! } public enum SwipeDirection { case left case right case none } public struct ButtonBarPagerTabStripSettings { public struct Style { public var buttonBarBackgroundColor: UIColor? public var buttonBarMinimumInteritemSpacing: CGFloat? public var buttonBarMinimumLineSpacing: CGFloat? public var selectedBarBackgroundColor = UIColor.black public var selectedBarHeight: CGFloat = 5 public var selectedBarVerticalAlignment: SelectedBarVerticalAlignment = .bottom public var buttonBarItemFont = UIFont.systemFont(ofSize: 18) public var buttonBarItemLeftRightMargin: CGFloat = 8 public var buttonBarItemTitleColor: UIColor? public var buttonBarItemsShouldFillAvailableWidth = true // only used if button bar is created programaticaly and not using storyboards or nib files public var buttonBarHeight: CGFloat? } public var style = Style() } public struct IndicatorInfo { public var title: String? public var image: UIImage? public var accessibilityLabel: String? public init(title: String?, image: UIImage?) { self.title = title self.accessibilityLabel = title self.image = image } } public enum ButtonBarItemSpec { case nibFile(nibName: String, bundle: Bundle?, width:((IndicatorInfo)-> CGFloat)) case cellClass(width:((IndicatorInfo)-> CGFloat)) public var weight: ((IndicatorInfo) -> CGFloat) { switch self { case .cellClass(let widthCallback): return widthCallback case .nibFile(_, _, let widthCallback): return widthCallback } } } public enum PagerScroll { case no case yes case scrollOnlyIfOutOfScreen } public enum SelectedBarAlignment { case left case center case right case progressive } public enum SelectedBarVerticalAlignment { case top case middle case bottom } open class ButtonBarView: UICollectionView { open lazy var selectedBar: UIView = { [unowned self] in let bar = UIView(frame: CGRect(x: 0, y: self.frame.size.height - CGFloat(self.selectedBarHeight), width: 0, height: CGFloat(self.selectedBarHeight))) bar.layer.zPosition = 9999 return bar }() internal var selectedBarHeight: CGFloat = 4 { didSet { updateSelectedBarYPosition() } } var selectedBarVerticalAlignment: SelectedBarVerticalAlignment = .bottom var selectedBarAlignment: SelectedBarAlignment = .center var selectedIndex = 0 required public init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) addSubview(selectedBar) } public override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) addSubview(selectedBar) } open func moveTo(index: Int, animated: Bool, swipeDirection: SwipeDirection, pagerScroll: PagerScroll) { selectedIndex = index updateSelectedBarPosition(animated, swipeDirection: swipeDirection, pagerScroll: pagerScroll) } open func move(fromIndex: Int, toIndex: Int, progressPercentage: CGFloat, pagerScroll: PagerScroll) { selectedIndex = progressPercentage > 0.5 ? toIndex : fromIndex let fromFrame = layoutAttributesForItem(at: IndexPath(item: fromIndex, section: 0))!.frame let numberOfItems = dataSource!.collectionView(self, numberOfItemsInSection: 0) var toFrame: CGRect if toIndex < 0 || toIndex > numberOfItems - 1 { if toIndex < 0 { let cellAtts = layoutAttributesForItem(at: IndexPath(item: 0, section: 0)) toFrame = cellAtts!.frame.offsetBy(dx: -cellAtts!.frame.size.width, dy: 0) } else { let cellAtts = layoutAttributesForItem(at: IndexPath(item: (numberOfItems - 1), section: 0)) toFrame = cellAtts!.frame.offsetBy(dx: cellAtts!.frame.size.width, dy: 0) } } else { toFrame = layoutAttributesForItem(at: IndexPath(item: toIndex, section: 0))!.frame } var targetFrame = fromFrame targetFrame.size.height = selectedBar.frame.size.height targetFrame.size.width += (toFrame.size.width - fromFrame.size.width) * progressPercentage targetFrame.origin.x += (toFrame.origin.x - fromFrame.origin.x) * progressPercentage selectedBar.frame = CGRect(x: targetFrame.origin.x, y: selectedBar.frame.origin.y, width: targetFrame.size.width, height: selectedBar.frame.size.height) var targetContentOffset: CGFloat = 0.0 if contentSize.width > frame.size.width { let toContentOffset = contentOffsetForCell(withFrame: toFrame, andIndex: toIndex) let fromContentOffset = contentOffsetForCell(withFrame: fromFrame, andIndex: fromIndex) targetContentOffset = fromContentOffset + ((toContentOffset - fromContentOffset) * progressPercentage) } setContentOffset(CGPoint(x: targetContentOffset, y: 0), animated: false) } open func updateSelectedBarPosition(_ animated: Bool, swipeDirection: SwipeDirection, pagerScroll: PagerScroll) { var selectedBarFrame = selectedBar.frame let selectedCellIndexPath = IndexPath(item: selectedIndex, section: 0) let attributes = layoutAttributesForItem(at: selectedCellIndexPath) let selectedCellFrame = attributes!.frame updateContentOffset(animated: animated, pagerScroll: pagerScroll, toFrame: selectedCellFrame, toIndex: (selectedCellIndexPath as NSIndexPath).row) selectedBarFrame.size.width = selectedCellFrame.size.width selectedBarFrame.origin.x = selectedCellFrame.origin.x if animated { UIView.animate(withDuration: 0.3, animations: { [weak self] in self?.selectedBar.frame = selectedBarFrame }) } else { selectedBar.frame = selectedBarFrame } } // MARK: - Helpers private func updateContentOffset(animated: Bool, pagerScroll: PagerScroll, toFrame: CGRect, toIndex: Int) { guard pagerScroll != .no || (pagerScroll != .scrollOnlyIfOutOfScreen && (toFrame.origin.x < contentOffset.x || toFrame.origin.x >= (contentOffset.x + frame.size.width - contentInset.left))) else { return } let targetContentOffset = contentSize.width > frame.size.width ? contentOffsetForCell(withFrame: toFrame, andIndex: toIndex) : 0 setContentOffset(CGPoint(x: targetContentOffset, y: 0), animated: animated) } private func contentOffsetForCell(withFrame cellFrame: CGRect, andIndex index: Int) -> CGFloat { let sectionInset = (collectionViewLayout as! UICollectionViewFlowLayout).sectionInset // swiftlint:disable:this force_cast var alignmentOffset: CGFloat = 0.0 switch selectedBarAlignment { case .left: alignmentOffset = sectionInset.left case .right: alignmentOffset = frame.size.width - sectionInset.right - cellFrame.size.width case .center: alignmentOffset = (frame.size.width - cellFrame.size.width) * 0.5 case .progressive: let cellHalfWidth = cellFrame.size.width * 0.5 let leftAlignmentOffset = sectionInset.left + cellHalfWidth let rightAlignmentOffset = frame.size.width - sectionInset.right - cellHalfWidth let numberOfItems = dataSource!.collectionView(self, numberOfItemsInSection: 0) let progress = index / (numberOfItems - 1) alignmentOffset = leftAlignmentOffset + (rightAlignmentOffset - leftAlignmentOffset) * CGFloat(progress) - cellHalfWidth } var contentOffset = cellFrame.origin.x - alignmentOffset contentOffset = max(0, contentOffset) contentOffset = min(contentSize.width - frame.size.width, contentOffset) return contentOffset } private func updateSelectedBarYPosition() { var selectedBarFrame = selectedBar.frame switch selectedBarVerticalAlignment { case .top: selectedBarFrame.origin.y = 0 case .middle: selectedBarFrame.origin.y = (frame.size.height - selectedBarHeight) / 2 case .bottom: selectedBarFrame.origin.y = frame.size.height - selectedBarHeight } selectedBarFrame.size.height = selectedBarHeight selectedBar.frame = selectedBarFrame } override open func layoutSubviews() { super.layoutSubviews() updateSelectedBarYPosition() } } open class BaseButtonBarPagerTabStripViewController: PagerTabStripViewController, PagerTabStripDataSource, PagerTabStripIsProgressiveDelegate, UICollectionViewDelegate, UICollectionViewDataSource { public var settings = ButtonBarPagerTabStripSettings() public var buttonBarItemSpec: ButtonBarItemSpec! public var changeCurrentIndex: ((_ oldCell: ButtonBarCellType?, _ newCell: ButtonBarCellType?, _ animated: Bool) -> Void)? public var changeCurrentIndexProgressive: ((_ oldCell: ButtonBarCellType?, _ newCell: ButtonBarCellType?, _ progressPercentage: CGFloat, _ changeCurrentIndex: Bool, _ animated: Bool) -> Void)? @IBOutlet public weak var buttonBarView: ButtonBarView! lazy private var cachedCellWidths: [CGFloat]? = { [unowned self] in return self.calculateWidths() }() public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) delegate = self datasource = self } required public init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) delegate = self datasource = self } open override func viewDidLoad() { super.viewDidLoad() let buttonBarViewAux = buttonBarView ?? { let flowLayout = UICollectionViewFlowLayout() flowLayout.scrollDirection = .horizontal let buttonBarHeight = settings.style.buttonBarHeight ?? 44 let yPos: CGFloat; if #available(iOS 11.0, *) { yPos = navigationController!.navigationBar.safeAreaInsets.top + navigationController!.navigationBar.frame.height } else { yPos = navigationController!.navigationBar.frame.maxY } let buttonBar = ButtonBarView(frame: CGRect(x: 0, y: yPos, width: view.frame.size.width, height: buttonBarHeight), collectionViewLayout: flowLayout) buttonBar.backgroundColor = .orange buttonBar.selectedBar.backgroundColor = .black buttonBar.autoresizingMask = .flexibleWidth var newContainerViewFrame = containerView.frame newContainerViewFrame.origin.y = buttonBar.frame.maxY newContainerViewFrame.size.height = containerView.frame.size.height - (buttonBar.frame.maxY - containerView.frame.origin.y) containerView.frame = newContainerViewFrame return buttonBar }() buttonBarView = buttonBarViewAux if buttonBarView.superview == nil { view.addSubview(buttonBarView) } if buttonBarView.delegate == nil { buttonBarView.delegate = self } if buttonBarView.dataSource == nil { buttonBarView.dataSource = self } buttonBarView.scrollsToTop = false let flowLayout = buttonBarView.collectionViewLayout as! UICollectionViewFlowLayout // swiftlint:disable:this force_cast flowLayout.scrollDirection = .horizontal flowLayout.minimumInteritemSpacing = settings.style.buttonBarMinimumInteritemSpacing ?? flowLayout.minimumInteritemSpacing flowLayout.minimumLineSpacing = settings.style.buttonBarMinimumLineSpacing ?? flowLayout.minimumLineSpacing buttonBarView.showsHorizontalScrollIndicator = false buttonBarView.backgroundColor = settings.style.buttonBarBackgroundColor ?? buttonBarView.backgroundColor buttonBarView.selectedBar.backgroundColor = settings.style.selectedBarBackgroundColor buttonBarView.selectedBarHeight = settings.style.selectedBarHeight // register button bar item cell switch buttonBarItemSpec! { case .nibFile(let nibName, let bundle, _): buttonBarView.register(UINib(nibName: nibName, bundle: bundle), forCellWithReuseIdentifier:"Cell") case .cellClass: buttonBarView.register(ButtonBarCellType.self, forCellWithReuseIdentifier:"Cell") } } open override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) buttonBarView.layoutIfNeeded() isViewAppearing = true } open override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) isViewAppearing = false } open override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() guard isViewAppearing || isViewRotating else { return } // Force the UICollectionViewFlowLayout to get laid out again with the new size if // a) The view is appearing. This ensures that // collectionView:layout:sizeForItemAtIndexPath: is called for a second time // when the view is shown and when the view *frame(s)* are actually set // (we need the view frame's to have been set to work out the size's and on the // first call to collectionView:layout:sizeForItemAtIndexPath: the view frame(s) // aren't set correctly) // b) The view is rotating. This ensures that // collectionView:layout:sizeForItemAtIndexPath: is called again and can use the views // *new* frame so that the buttonBarView cell's actually get resized correctly cachedCellWidths = calculateWidths() buttonBarView.collectionViewLayout.invalidateLayout() // When the view first appears or is rotated we also need to ensure that the barButtonView's // selectedBar is resized and its contentOffset/scroll is set correctly (the selected // tab/cell may end up either skewed or off screen after a rotation otherwise) buttonBarView.moveTo(index: currentIndex, animated: false, swipeDirection: .none, pagerScroll: .scrollOnlyIfOutOfScreen) buttonBarView.selectItem(at: IndexPath(item: currentIndex, section: 0), animated: false, scrollPosition: []) } // MARK: - View Rotation open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) let yPos: CGFloat; if #available(iOS 11.0, *) { yPos = navigationController!.navigationBar.safeAreaInsets.top + navigationController!.navigationBar.frame.height } else { yPos = navigationController!.navigationBar.frame.maxY } if let buttonBarView = buttonBarView { buttonBarView.frame = CGRect(x: 0, y: yPos, width: view.frame.size.width, height: buttonBarView.frame.height) var newContainerViewFrame = containerView.frame newContainerViewFrame.origin.y = buttonBarView.frame.maxY newContainerViewFrame.size.height = containerView.frame.size.height - (buttonBarView.frame.maxY - containerView.frame.origin.y) containerView.frame = newContainerViewFrame } } // MARK: - Public Methods open override func reloadPagerTabStripView() { super.reloadPagerTabStripView() guard isViewLoaded else { return } buttonBarView.reloadData() cachedCellWidths = calculateWidths() buttonBarView.moveTo(index: currentIndex, animated: false, swipeDirection: .none, pagerScroll: .yes) } open func calculateStretchedCellWidths(_ minimumCellWidths: [CGFloat], suggestedStretchedCellWidth: CGFloat, previousNumberOfLargeCells: Int) -> CGFloat { var numberOfLargeCells = 0 var totalWidthOfLargeCells: CGFloat = 0 for minimumCellWidthValue in minimumCellWidths where minimumCellWidthValue > suggestedStretchedCellWidth { totalWidthOfLargeCells += minimumCellWidthValue numberOfLargeCells += 1 } guard numberOfLargeCells > previousNumberOfLargeCells else { return suggestedStretchedCellWidth } let flowLayout = buttonBarView.collectionViewLayout as! UICollectionViewFlowLayout // swiftlint:disable:this force_cast let collectionViewAvailiableWidth = buttonBarView.frame.size.width - flowLayout.sectionInset.left - flowLayout.sectionInset.right let numberOfCells = minimumCellWidths.count let cellSpacingTotal = CGFloat(numberOfCells - 1) * flowLayout.minimumLineSpacing let numberOfSmallCells = numberOfCells - numberOfLargeCells let newSuggestedStretchedCellWidth = (collectionViewAvailiableWidth - totalWidthOfLargeCells - cellSpacingTotal) / CGFloat(numberOfSmallCells) return calculateStretchedCellWidths(minimumCellWidths, suggestedStretchedCellWidth: newSuggestedStretchedCellWidth, previousNumberOfLargeCells: numberOfLargeCells) } open func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int) { guard shouldUpdateButtonBarView else { return } buttonBarView.moveTo(index: toIndex, animated: true, swipeDirection: toIndex < fromIndex ? .right : .left, pagerScroll: .yes) if let changeCurrentIndex = changeCurrentIndex { let oldCell = buttonBarView.cellForItem(at: IndexPath(item: currentIndex != fromIndex ? fromIndex : toIndex, section: 0)) as? ButtonBarCellType let newCell = buttonBarView.cellForItem(at: IndexPath(item: currentIndex, section: 0)) as? ButtonBarCellType changeCurrentIndex(oldCell, newCell, true) } } open func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int, withProgressPercentage progressPercentage: CGFloat, indexWasChanged: Bool) { guard shouldUpdateButtonBarView else { return } buttonBarView.move(fromIndex: fromIndex, toIndex: toIndex, progressPercentage: progressPercentage, pagerScroll: .yes) if let changeCurrentIndexProgressive = changeCurrentIndexProgressive { let oldCell = buttonBarView.cellForItem(at: IndexPath(item: currentIndex != fromIndex ? fromIndex : toIndex, section: 0)) as? ButtonBarCellType let newCell = buttonBarView.cellForItem(at: IndexPath(item: currentIndex, section: 0)) as? ButtonBarCellType changeCurrentIndexProgressive(oldCell, newCell, progressPercentage, indexWasChanged, true) } } // MARK: - UICollectionViewDelegateFlowLayut @objc open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize { guard let cellWidthValue = cachedCellWidths?[indexPath.row] else { fatalError("cachedCellWidths for \(indexPath.row) must not be nil") } return CGSize(width: cellWidthValue, height: collectionView.frame.size.height) } open func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard indexPath.item != currentIndex else { return } buttonBarView.moveTo(index: indexPath.item, animated: true, swipeDirection: .none, pagerScroll: .yes) shouldUpdateButtonBarView = false let oldCell = buttonBarView.cellForItem(at: IndexPath(item: currentIndex, section: 0)) as? ButtonBarCellType let newCell = buttonBarView.cellForItem(at: IndexPath(item: indexPath.item, section: 0)) as? ButtonBarCellType if let changeCurrentIndexProgressive = changeCurrentIndexProgressive { changeCurrentIndexProgressive(oldCell, newCell, 1, true, true) } moveToViewController(at: indexPath.item) } // MARK: - UICollectionViewDataSource open func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return viewControllers.count } open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as? ButtonBarCellType else { fatalError("UICollectionViewCell should be or extend from ButtonBarViewCell") } let childController = viewControllers[indexPath.item] as! IndicatorInfoProvider // swiftlint:disable:this force_cast let indicatorInfo = childController.indicatorInfo(for: self) configure(cell: cell, for: indicatorInfo) if let changeCurrentIndexProgressive = changeCurrentIndexProgressive { changeCurrentIndexProgressive(currentIndex == indexPath.item ? nil : cell, currentIndex == indexPath.item ? cell : nil, 1, true, false) } return cell } // MARK: - UIScrollViewDelegate open override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { super.scrollViewDidEndScrollingAnimation(scrollView) guard scrollView == containerView else { return } shouldUpdateButtonBarView = true } open func configure(cell: ButtonBarCellType, for indicatorInfo: IndicatorInfo) { fatalError("You must override this method to set up ButtonBarView cell accordingly") } private func calculateWidths() -> [CGFloat] { let flowLayout = buttonBarView.collectionViewLayout as! UICollectionViewFlowLayout // swiftlint:disable:this force_cast let numberOfCells = viewControllers.count var minimumCellWidths = [CGFloat]() var collectionViewContentWidth: CGFloat = 0 for viewController in viewControllers { let childController = viewController as! IndicatorInfoProvider // swiftlint:disable:this force_cast let indicatorInfo = childController.indicatorInfo(for: self) switch buttonBarItemSpec! { case .cellClass(let widthCallback): let width = widthCallback(indicatorInfo) minimumCellWidths.append(width) collectionViewContentWidth += width case .nibFile(_, _, let widthCallback): let width = widthCallback(indicatorInfo) minimumCellWidths.append(width) collectionViewContentWidth += width } } let cellSpacingTotal = CGFloat(numberOfCells - 1) * flowLayout.minimumLineSpacing collectionViewContentWidth += cellSpacingTotal let collectionViewAvailableVisibleWidth = buttonBarView.frame.size.width - flowLayout.sectionInset.left - flowLayout.sectionInset.right if !settings.style.buttonBarItemsShouldFillAvailableWidth || collectionViewAvailableVisibleWidth < collectionViewContentWidth { return minimumCellWidths } else { let stretchedCellWidthIfAllEqual = (collectionViewAvailableVisibleWidth - cellSpacingTotal) / CGFloat(numberOfCells) let generalMinimumCellWidth = calculateStretchedCellWidths(minimumCellWidths, suggestedStretchedCellWidth: stretchedCellWidthIfAllEqual, previousNumberOfLargeCells: 0) var stretchedCellWidths = [CGFloat]() for minimumCellWidthValue in minimumCellWidths { let cellWidth = (minimumCellWidthValue > generalMinimumCellWidth) ? minimumCellWidthValue : generalMinimumCellWidth stretchedCellWidths.append(cellWidth) } return stretchedCellWidths } } private var shouldUpdateButtonBarView = true } // MARK: Protocols public protocol IndicatorInfoProvider { func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo } public protocol PagerTabStripDelegate: class { func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int) } public protocol PagerTabStripIsProgressiveDelegate: PagerTabStripDelegate { func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int, withProgressPercentage progressPercentage: CGFloat, indexWasChanged: Bool) } public protocol PagerTabStripDataSource: class { func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] } // MARK: PagerTabStripViewController open class PagerTabStripViewController: UIViewController, UIScrollViewDelegate { @IBOutlet weak public var containerView: UIScrollView! open weak var delegate: PagerTabStripDelegate? open weak var datasource: PagerTabStripDataSource? open private(set) var viewControllers = [UIViewController]() open private(set) var currentIndex = 0 open private(set) var preCurrentIndex = 0 // used *only* to store the index to which move when the pager becomes visible open var pageWidth: CGFloat { return containerView.bounds.width } open var scrollPercentage: CGFloat { if swipeDirection != .right { let module = fmod(containerView.contentOffset.x, pageWidth) return module == 0.0 ? 1.0 : module / pageWidth } return 1 - fmod(containerView.contentOffset.x >= 0 ? containerView.contentOffset.x : pageWidth + containerView.contentOffset.x, pageWidth) / pageWidth } open var swipeDirection: SwipeDirection { if containerView.contentOffset.x > lastContentOffset { return .left } else if containerView.contentOffset.x < lastContentOffset { return .right } return .none } override open func viewDidLoad() { super.viewDidLoad() let containerViewAux = containerView ?? { let containerView = UIScrollView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height)) containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] return containerView }() containerView = containerViewAux if containerView.superview == nil { view.addSubview(containerView) } containerView.bounces = true containerView.alwaysBounceHorizontal = true containerView.alwaysBounceVertical = false containerView.scrollsToTop = false containerView.delegate = self containerView.showsVerticalScrollIndicator = false containerView.showsHorizontalScrollIndicator = false containerView.isPagingEnabled = true containerView.backgroundColor = PresentationTheme.current.colors.background reloadViewControllers() let childController = viewControllers[currentIndex] addChildViewController(childController) childController.view.autoresizingMask = [.flexibleHeight, .flexibleWidth] containerView.addSubview(childController.view) childController.didMove(toParentViewController: self) } open override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) isViewAppearing = true childViewControllers.forEach { $0.beginAppearanceTransition(true, animated: animated) } } override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) lastSize = containerView.bounds.size updateIfNeeded() let needToUpdateCurrentChild = preCurrentIndex != currentIndex if needToUpdateCurrentChild { moveToViewController(at: preCurrentIndex) } isViewAppearing = false childViewControllers.forEach { $0.endAppearanceTransition() } } open override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) childViewControllers.forEach { $0.beginAppearanceTransition(false, animated: animated) } } open override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) childViewControllers.forEach { $0.endAppearanceTransition() } } override open func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() updateIfNeeded() } open override var shouldAutomaticallyForwardAppearanceMethods: Bool { return false } open func moveToViewController(at index: Int, animated: Bool = true) { guard isViewLoaded && view.window != nil && currentIndex != index else { preCurrentIndex = index return } if animated && abs(currentIndex - index) > 1 { var tmpViewControllers = viewControllers let currentChildVC = viewControllers[currentIndex] let fromIndex = currentIndex < index ? index - 1 : index + 1 let fromChildVC = viewControllers[fromIndex] tmpViewControllers[currentIndex] = fromChildVC tmpViewControllers[fromIndex] = currentChildVC pagerTabStripChildViewControllersForScrolling = tmpViewControllers containerView.setContentOffset(CGPoint(x: pageOffsetForChild(at: fromIndex), y: 0), animated: false) (navigationController?.view ?? view).isUserInteractionEnabled = !animated containerView.setContentOffset(CGPoint(x: pageOffsetForChild(at: index), y: 0), animated: true) } else { (navigationController?.view ?? view).isUserInteractionEnabled = !animated containerView.setContentOffset(CGPoint(x: pageOffsetForChild(at: index), y: 0), animated: animated) } } open func moveTo(viewController: UIViewController, animated: Bool = true) { moveToViewController(at: viewControllers.index(of: viewController)!, animated: animated) } // MARK: - PagerTabStripDataSource open func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] { assertionFailure("Sub-class must implement the PagerTabStripDataSource viewControllers(for:) method") return [] } // MARK: - Helpers public enum PagerTabStripError: Error { case viewControllerOutOfBounds } open func updateIfNeeded() { if isViewLoaded && !lastSize.equalTo(containerView.bounds.size) { updateContent() } } open func canMoveTo(index: Int) -> Bool { return currentIndex != index && viewControllers.count > index } open func pageOffsetForChild(at index: Int) -> CGFloat { return CGFloat(index) * containerView.bounds.width } open func offsetForChild(at index: Int) -> CGFloat { return (CGFloat(index) * containerView.bounds.width) + ((containerView.bounds.width - view.bounds.width) * 0.5) } open func offsetForChild(viewController: UIViewController) throws -> CGFloat { guard let index = viewControllers.index(of: viewController) else { throw PagerTabStripError.viewControllerOutOfBounds } return offsetForChild(at: index) } open func pageFor(contentOffset: CGFloat) -> Int { let result = virtualPageFor(contentOffset: contentOffset) return pageFor(virtualPage: result) } open func virtualPageFor(contentOffset: CGFloat) -> Int { return Int((contentOffset + 1.5 * pageWidth) / pageWidth) - 1 } open func pageFor(virtualPage: Int) -> Int { if virtualPage < 0 { return 0 } if virtualPage > viewControllers.count - 1 { return viewControllers.count - 1 } return virtualPage } open func updateContent() { if lastSize.width != containerView.bounds.size.width { lastSize = containerView.bounds.size containerView.contentOffset = CGPoint(x: pageOffsetForChild(at: currentIndex), y: 0) } lastSize = containerView.bounds.size let pagerViewControllers = pagerTabStripChildViewControllersForScrolling ?? viewControllers containerView.contentSize = CGSize(width: containerView.bounds.width * CGFloat(pagerViewControllers.count), height: containerView.contentSize.height) for (index, childController) in pagerViewControllers.enumerated() { let pageOffsetForChild = self.pageOffsetForChild(at: index) if fabs(containerView.contentOffset.x - pageOffsetForChild) < containerView.bounds.width { if childController.parent != nil { childController.view.frame = CGRect(x: offsetForChild(at: index), y: 0, width: view.bounds.width, height: containerView.bounds.height) childController.view.autoresizingMask = [.flexibleHeight, .flexibleWidth] } else { childController.beginAppearanceTransition(true, animated: false) addChildViewController(childController) childController.view.frame = CGRect(x: offsetForChild(at: index), y: 0, width: view.bounds.width, height: containerView.bounds.height) childController.view.autoresizingMask = [.flexibleHeight, .flexibleWidth] containerView.addSubview(childController.view) childController.didMove(toParentViewController: self) childController.endAppearanceTransition() } } else { if childController.parent != nil { childController.beginAppearanceTransition(false, animated: false) childController.willMove(toParentViewController: nil) childController.view.removeFromSuperview() childController.removeFromParentViewController() childController.endAppearanceTransition() } } } let oldCurrentIndex = currentIndex let virtualPage = virtualPageFor(contentOffset: containerView.contentOffset.x) let newCurrentIndex = pageFor(virtualPage: virtualPage) currentIndex = newCurrentIndex preCurrentIndex = currentIndex let changeCurrentIndex = newCurrentIndex != oldCurrentIndex if let progressiveDelegate = self as? PagerTabStripIsProgressiveDelegate { let (fromIndex, toIndex, scrollPercentage) = progressiveIndicatorData(virtualPage) progressiveDelegate.updateIndicator(for: self, fromIndex: fromIndex, toIndex: toIndex, withProgressPercentage: scrollPercentage, indexWasChanged: changeCurrentIndex) } else { delegate?.updateIndicator(for: self, fromIndex: min(oldCurrentIndex, pagerViewControllers.count - 1), toIndex: newCurrentIndex) } } open func reloadPagerTabStripView() { guard isViewLoaded else { return } for childController in viewControllers where childController.parent != nil { childController.beginAppearanceTransition(false, animated: false) childController.willMove(toParentViewController: nil) childController.view.removeFromSuperview() childController.removeFromParentViewController() childController.endAppearanceTransition() } reloadViewControllers() containerView.contentSize = CGSize(width: containerView.bounds.width * CGFloat(viewControllers.count), height: containerView.contentSize.height) if currentIndex >= viewControllers.count { currentIndex = viewControllers.count - 1 } preCurrentIndex = currentIndex containerView.contentOffset = CGPoint(x: pageOffsetForChild(at: currentIndex), y: 0) updateContent() } // MARK: - UIScrollViewDelegate open func scrollViewDidScroll(_ scrollView: UIScrollView) { if containerView == scrollView { updateContent() lastContentOffset = scrollView.contentOffset.x } } open func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if containerView == scrollView { lastPageNumber = pageFor(contentOffset: scrollView.contentOffset.x) } } open func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { if containerView == scrollView { pagerTabStripChildViewControllersForScrolling = nil (navigationController?.view ?? view).isUserInteractionEnabled = true updateContent() } } // MARK: - Orientation open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) isViewRotating = true pageBeforeRotate = currentIndex coordinator.animate(alongsideTransition: nil) { [weak self] _ in guard let me = self else { return } me.isViewRotating = false me.currentIndex = me.pageBeforeRotate me.preCurrentIndex = me.currentIndex me.updateIfNeeded() } } // MARK: Private private func progressiveIndicatorData(_ virtualPage: Int) -> (Int, Int, CGFloat) { let count = viewControllers.count var fromIndex = currentIndex var toIndex = currentIndex let direction = swipeDirection if direction == .left { if virtualPage > count - 1 { fromIndex = count - 1 toIndex = count } else { if self.scrollPercentage >= 0.5 { fromIndex = max(toIndex - 1, 0) } else { toIndex = fromIndex + 1 } } } else if direction == .right { if virtualPage < 0 { fromIndex = 0 toIndex = -1 } else { if self.scrollPercentage > 0.5 { fromIndex = min(toIndex + 1, count - 1) } else { toIndex = fromIndex - 1 } } } return (fromIndex, toIndex, self.scrollPercentage) } private func reloadViewControllers() { guard let dataSource = datasource else { fatalError("dataSource must not be nil") } viewControllers = dataSource.viewControllers(for: self) // viewControllers guard !viewControllers.isEmpty else { fatalError("viewControllers(for:) should provide at least one child view controller") } viewControllers.forEach { if !($0 is IndicatorInfoProvider) { fatalError("Every view controller provided by PagerTabStripDataSource's viewControllers(for:) method must conform to IndicatorInfoProvider") }} } private var pagerTabStripChildViewControllersForScrolling: [UIViewController]? private var lastPageNumber = 0 private var lastContentOffset: CGFloat = 0.0 private var pageBeforeRotate = 0 private var lastSize = CGSize(width: 0, height: 0) internal var isViewRotating = false internal var isViewAppearing = false }