// // ZLImagePreviewController.swift // ZLPhotoBrowser // // Created by long on 2020/10/22. // // Copyright (c) 2020 Long Zhang <495181165@qq.com> // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import UIKit import Photos @objc public enum ZLURLType: Int { case image case video } public typealias ZLImageLoaderBlock = (_ url: URL, _ imageView: UIImageView, _ progress: @escaping (CGFloat) -> Void, _ complete: @escaping () -> Void) -> Void public class ZLImagePreviewController: UIViewController { static let colItemSpacing: CGFloat = 40 static let selPhotoPreviewH: CGFloat = 100 private let datas: [Any] private var selectStatus: [Bool] private let urlType: ((URL) -> ZLURLType)? private let urlImageLoader: ZLImageLoaderBlock? private let showSelectBtn: Bool private let showBottomView: Bool private var currentIndex: Int private var indexBeforOrientationChanged: Int private lazy var collectionView: UICollectionView = { let layout = ZLCollectionViewFlowLayout() layout.scrollDirection = .horizontal let view = UICollectionView(frame: .zero, collectionViewLayout: layout) view.backgroundColor = .clear view.dataSource = self view.delegate = self view.isPagingEnabled = true view.showsHorizontalScrollIndicator = false ZLPhotoPreviewCell.zl.register(view) ZLGifPreviewCell.zl.register(view) ZLLivePhotoPreviewCell.zl.register(view) ZLVideoPreviewCell.zl.register(view) ZLLocalImagePreviewCell.zl.register(view) ZLNetImagePreviewCell.zl.register(view) ZLNetVideoPreviewCell.zl.register(view) return view }() private lazy var navView: UIView = { let view = UIView() view.backgroundColor = .zl.navBarColorOfPreviewVC return view }() private var navBlurView: UIVisualEffectView? private lazy var backBtn: UIButton = { let btn = UIButton(type: .custom) var image = UIImage.zl.getImage("zl_navBack") if isRTL() { image = image?.imageFlippedForRightToLeftLayoutDirection() btn.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: -10) } else { btn.imageEdgeInsets = UIEdgeInsets(top: 0, left: -10, bottom: 0, right: 0) } btn.setImage(image, for: .normal) btn.addTarget(self, action: #selector(backBtnClick), for: .touchUpInside) return btn }() private lazy var indexLabel: UILabel = { let label = UILabel() label.textColor = .zl.indexLabelTextColor label.font = ZLLayout.navTitleFont label.textAlignment = .center return label }() private lazy var selectBtn: ZLEnlargeButton = { let btn = ZLEnlargeButton(type: .custom) btn.setImage(.zl.getImage("zl_btn_circle"), for: .normal) btn.setImage(.zl.getImage("zl_btn_selected"), for: .selected) btn.enlargeInset = 10 btn.addTarget(self, action: #selector(selectBtnClick), for: .touchUpInside) return btn }() private lazy var bottomView: UIView = { let view = UIView() view.backgroundColor = .zl.bottomToolViewBgColorOfPreviewVC return view }() private var bottomBlurView: UIVisualEffectView? private lazy var doneBtn: UIButton = { let btn = UIButton(type: .custom) btn.titleLabel?.font = ZLLayout.bottomToolTitleFont btn.setTitle(title, for: .normal) btn.setTitleColor(.zl.bottomToolViewDoneBtnNormalTitleColorOfPreviewVC, for: .normal) btn.setTitleColor(.zl.bottomToolViewDoneBtnDisableTitleColorOfPreviewVC, for: .disabled) btn.addTarget(self, action: #selector(doneBtnClick), for: .touchUpInside) btn.backgroundColor = .zl.bottomToolViewBtnNormalBgColorOfPreviewVC btn.layer.masksToBounds = true btn.layer.cornerRadius = ZLLayout.bottomToolBtnCornerRadius return btn }() private var isFirstAppear = true private var hideNavView = false private var orientation: UIInterfaceOrientation = .unknown @objc public var longPressBlock: ((ZLImagePreviewController?, UIImage?, Int) -> Void)? @objc public var doneBlock: (([Any]) -> Void)? @objc public var videoHttpHeader: [String: Any]? override public var prefersStatusBarHidden: Bool { return !ZLPhotoUIConfiguration.default().showStatusBarInPreviewInterface } override public var preferredStatusBarStyle: UIStatusBarStyle { return ZLPhotoUIConfiguration.default().statusBarStyle } deinit { zl_debugPrint("ZLImagePreviewController deinit") } /// - Parameters: /// - datas: Must be one of PHAsset, UIImage and URL, will filter others in init function. /// - showBottomView: If showSelectBtn is true, showBottomView is always true. /// - index: Index for first display. /// - urlType: Tell me the url is image or video. /// - urlImageLoader: Called when cell will display, cell will layout after callback when image load finish. The first block is progress callback, second is load finish callback. @objc public init( datas: [Any], index: Int = 0, showSelectBtn: Bool = true, showBottomView: Bool = true, urlType: ((URL) -> ZLURLType)? = nil, urlImageLoader: ZLImageLoaderBlock? = nil ) { let filterDatas = datas.filter { $0 is PHAsset || $0 is UIImage || $0 is URL } self.datas = filterDatas selectStatus = Array(repeating: true, count: filterDatas.count) currentIndex = min(index, filterDatas.count - 1) indexBeforOrientationChanged = currentIndex self.showSelectBtn = showSelectBtn self.showBottomView = showSelectBtn ? true : showBottomView self.urlType = urlType self.urlImageLoader = urlImageLoader super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } override public func viewDidLoad() { super.viewDidLoad() setupUI() resetSubViewStatus() } override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.navigationBar.isHidden = true } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) guard isFirstAppear else { return } isFirstAppear = false reloadCurrentCell() } override public func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() var insets = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0) if #available(iOS 11.0, *) { insets = view.safeAreaInsets } insets.top = max(20, insets.top) collectionView.frame = CGRect( x: -ZLPhotoPreviewController.colItemSpacing / 2, y: 0, width: view.zl.width + ZLPhotoPreviewController.colItemSpacing, height: view.zl.height ) let navH = insets.top + 44 navView.frame = CGRect(x: 0, y: 0, width: view.zl.width, height: navH) navBlurView?.frame = navView.bounds indexLabel.frame = CGRect(x: (view.zl.width - 80) / 2, y: insets.top, width: 80, height: 44) if isRTL() { backBtn.frame = CGRect(x: view.zl.width - insets.right - 60, y: insets.top, width: 60, height: 44) selectBtn.frame = CGRect(x: insets.left + 15, y: insets.top + (44 - 25) / 2, width: 25, height: 25) } else { backBtn.frame = CGRect(x: insets.left, y: insets.top, width: 60, height: 44) selectBtn.frame = CGRect(x: view.zl.width - 40 - insets.right, y: insets.top + (44 - 25) / 2, width: 25, height: 25) } let bottomViewH = ZLLayout.bottomToolViewH bottomView.frame = CGRect(x: 0, y: view.zl.height - insets.bottom - bottomViewH, width: view.zl.width, height: bottomViewH + insets.bottom) bottomBlurView?.frame = bottomView.bounds resetBottomViewFrame() let ori = UIApplication.shared.statusBarOrientation if ori != orientation { orientation = ori collectionView.setContentOffset( CGPoint( x: (view.zl.width + ZLPhotoPreviewController.colItemSpacing) * CGFloat(indexBeforOrientationChanged), y: 0 ), animated: false ) collectionView.performBatchUpdates({ self.collectionView.setContentOffset( CGPoint( x: (self.view.frame.width + ZLPhotoPreviewController.colItemSpacing) * CGFloat(self.indexBeforOrientationChanged), y: 0 ), animated: false ) }) } } private func reloadCurrentCell() { guard let cell = collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)) else { return } if let cell = cell as? ZLGifPreviewCell { cell.loadGifWhenCellDisplaying() } else if let cell = cell as? ZLLivePhotoPreviewCell { cell.loadLivePhotoData() } } private func setupUI() { view.backgroundColor = .zl.previewVCBgColor automaticallyAdjustsScrollViewInsets = false view.addSubview(navView) if let effect = ZLPhotoUIConfiguration.default().navViewBlurEffectOfPreview { navBlurView = UIVisualEffectView(effect: effect) navView.addSubview(navBlurView!) } navView.addSubview(backBtn) navView.addSubview(indexLabel) navView.addSubview(selectBtn) view.addSubview(collectionView) view.addSubview(bottomView) if let effect = ZLPhotoUIConfiguration.default().bottomViewBlurEffectOfPreview { bottomBlurView = UIVisualEffectView(effect: effect) bottomView.addSubview(bottomBlurView!) } bottomView.addSubview(doneBtn) view.bringSubviewToFront(navView) } private func resetSubViewStatus() { indexLabel.text = String(currentIndex + 1) + " / " + String(datas.count) if showSelectBtn { selectBtn.isSelected = selectStatus[currentIndex] } else { selectBtn.isHidden = true } resetBottomViewFrame() } private func resetBottomViewFrame() { guard showBottomView else { bottomView.isHidden = true return } let btnY = ZLLayout.bottomToolBtnY var doneTitle = localLanguageTextValue(.done) let selCount = selectStatus.filter { $0 }.count if showSelectBtn, ZLPhotoConfiguration.default().showSelectCountOnDoneBtn, selCount > 0 { doneTitle += "(" + String(selCount) + ")" } let doneBtnW = doneTitle.zl.boundingRect(font: ZLLayout.bottomToolTitleFont, limitSize: CGSize(width: CGFloat.greatestFiniteMagnitude, height: 30)).width + 20 doneBtn.frame = CGRect(x: bottomView.bounds.width - doneBtnW - 15, y: btnY, width: doneBtnW, height: ZLLayout.bottomToolBtnH) doneBtn.setTitle(doneTitle, for: .normal) } private func dismiss() { if let nav = navigationController { let vc = nav.popViewController(animated: true) if vc == nil { nav.dismiss(animated: true, completion: nil) } } else { dismiss(animated: true, completion: nil) } } // MARK: btn actions @objc private func backBtnClick() { dismiss() } @objc private func selectBtnClick() { var isSelected = selectStatus[currentIndex] selectBtn.layer.removeAllAnimations() if isSelected { isSelected = false } else { if ZLPhotoConfiguration.default().animateSelectBtnWhenSelect { selectBtn.layer.add(ZLAnimationUtils.springAnimation(), forKey: nil) } isSelected = true } selectStatus[currentIndex] = isSelected resetSubViewStatus() } @objc private func doneBtnClick() { if showSelectBtn { let res = datas.enumerated() .filter { self.selectStatus[$0.offset] } .map { $0.element } doneBlock?(res) } else { doneBlock?(datas) } dismiss() } private func tapPreviewCell() { hideNavView.toggle() let cell = collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)) if let cell = cell as? ZLVideoPreviewCell, cell.isPlaying { hideNavView = true } navView.isHidden = hideNavView if showBottomView { bottomView.isHidden = hideNavView } } } // scroll view delegate public extension ZLImagePreviewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView == collectionView else { return } NotificationCenter.default.post(name: ZLPhotoPreviewController.previewVCScrollNotification, object: nil) let offset = scrollView.contentOffset var page = Int(round(offset.x / (view.bounds.width + ZLPhotoPreviewController.colItemSpacing))) page = max(0, min(page, datas.count - 1)) if page == currentIndex { return } currentIndex = page resetSubViewStatus() } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { indexBeforOrientationChanged = currentIndex let cell = collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)) if let cell = cell as? ZLGifPreviewCell { cell.loadGifWhenCellDisplaying() } else if let cell = cell as? ZLLivePhotoPreviewCell { cell.loadLivePhotoData() } } } extension ZLImagePreviewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { return ZLImagePreviewController.colItemSpacing } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { return ZLImagePreviewController.colItemSpacing } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { return UIEdgeInsets(top: 0, left: ZLImagePreviewController.colItemSpacing / 2, bottom: 0, right: ZLImagePreviewController.colItemSpacing / 2) } public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: view.zl.width, height: view.zl.height) } public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return datas.count } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let config = ZLPhotoConfiguration.default() let obj = datas[indexPath.row] let baseCell: ZLPreviewBaseCell if let asset = obj as? PHAsset { let model = ZLPhotoModel(asset: asset) if config.allowSelectGif, model.type == .gif { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ZLGifPreviewCell.zl.identifier, for: indexPath) as! ZLGifPreviewCell cell.singleTapBlock = { [weak self] in self?.tapPreviewCell() } cell.model = model baseCell = cell } else if config.allowSelectLivePhoto, model.type == .livePhoto { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ZLLivePhotoPreviewCell.zl.identifier, for: indexPath) as! ZLLivePhotoPreviewCell cell.model = model baseCell = cell } else if config.allowSelectVideo, model.type == .video { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ZLVideoPreviewCell.zl.identifier, for: indexPath) as! ZLVideoPreviewCell cell.model = model baseCell = cell } else { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ZLPhotoPreviewCell.zl.identifier, for: indexPath) as! ZLPhotoPreviewCell cell.singleTapBlock = { [weak self] in self?.tapPreviewCell() } cell.model = model baseCell = cell } return baseCell } else if let image = obj as? UIImage { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ZLLocalImagePreviewCell.zl.identifier, for: indexPath) as! ZLLocalImagePreviewCell cell.image = image baseCell = cell } else if let url = obj as? URL { let type: ZLURLType = urlType?(url) ?? .image if type == .image { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ZLNetImagePreviewCell.zl.identifier, for: indexPath) as! ZLNetImagePreviewCell cell.image = nil urlImageLoader?(url, cell.preview.imageView, { [weak cell] progress in ZLMainAsync { cell?.progress = progress } }, { [weak cell] in ZLMainAsync { cell?.preview.resetSubViewSize() } }) baseCell = cell } else { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ZLNetVideoPreviewCell.zl.identifier, for: indexPath) as! ZLNetVideoPreviewCell cell.configureCell(videoUrl: url, httpHeader: videoHttpHeader) baseCell = cell } } else { #if DEBUG fatalError("Preview obj must one of PHAsset, UIImage, URL") #else return UICollectionViewCell() #endif } baseCell.singleTapBlock = { [weak self] in self?.tapPreviewCell() } (baseCell as? ZLLocalImagePreviewCell)?.longPressBlock = { [weak self, weak baseCell] in if let callback = self?.longPressBlock { callback(self, baseCell?.currentImage, indexPath.row) } else { self?.showSaveImageAlert() } } return baseCell } public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if let cell = cell as? ZLPreviewBaseCell { cell.resetSubViewStatusWhenCellEndDisplay() } } private func showSaveImageAlert() { func saveImage() { guard let cell = collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)) as? ZLLocalImagePreviewCell, let image = cell.currentImage else { return } let hud = ZLProgressHUD.show() ZLPhotoManager.saveImageToAlbum(image: image) { [weak self] suc, _ in hud.hide() if !suc { showAlertView(localLanguageTextValue(.saveImageError), self) } } } let saveAction = ZLCustomAlertAction(title: localLanguageTextValue(.save), style: .default) { _ in saveImage() } let cancelAction = ZLCustomAlertAction(title: localLanguageTextValue(.cancel), style: .cancel, handler: nil) showAlertController(title: nil, message: "", style: .actionSheet, actions: [saveAction, cancelAction], sender: self) } }