1. 引用的第三方库 SVProgressHUD,SDWebImage,SnapKit
2. 创建 PhotoBrowserController.swift
// PhotoBrowserController.swift
// Created by Hanyang Li on 2022/7/6.
//
import UIKit
import SVProgressHUD
///可重用 Cell 表示符号
private let PhotoBrowserViewCellId = "PhotoBrowserViewCellId"
//MARK: - 图片浏览器
class PhotoBrowserController: UIViewController {
///照片 URL 数组
private var urls: [URL]
///当前选中照片索引
private var currentIndexPath:IndexPath
//MARK: - 监听方法
@objc private func close(){
dismiss(animated: true)
}
//MARK: - 保存
@objc private func save(){
//1.拿到图片
let cell = collectionView.visibleCells[0] as! PhotoBrowserCell
//2. imageView 可能因为网络问题 没有图片 -> 下载需要提示
guard let image = cell.imageView.image else{
return
}
//3.保存图片
UIImageWriteToSavedPhotosAlbum(image, self, #selector(image(image:didFinishSavingWithError:contextInfo:)), nil)
}
/// 保存图片回调
/// - Parameters:
/// - image: 保存的图片
/// - error: 错误提示
/// - contextInfo: 相关信息
@objc private func image(image:UIImage, didFinishSavingWithError error: NSError?, contextInfo: Any?){
let message = (error == nil) ? "保存成功": "保存失败"
SVProgressHUD.showSuccess(withStatus: message)
}
/// 构造函数 属性都可以是必选,不用在后续考虑解包的问题
init(urls: [URL], indexPath: IndexPath){
self.urls = urls
self.currentIndexPath = indexPath
//调用父类方法
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// 和 xib & sb 等价 职责: 创建视图层次结构 loadView 函数执行完毕,view上的元素要全部创建完成
//如果 view == nil 系统会自动调用 view 的 getter 方法时, 自动调用 loadView 创建 view
override func loadView() {
//1.设置根视图
var rect = UIScreen.main.bounds
rect.size.width += 20
view = UIView(frame: rect)
//2.设置界面
setupUI()
}
//视图加载完成被调用,loadView 执行完毕被执行
//主要做数据加载,或者其他处理
//目前很多程序没有实现 loadView ,所以在建立子控件的代码都在 viewDidLoad 中
override func viewDidLoad() {
super.viewDidLoad()
//让 collectionView 滚动到指定的位置
collectionView.scrollToItem(at: currentIndexPath, at: .centeredHorizontally, animated: false)
}
//MARK: - 懒加载控件
private lazy var collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: PhotoBrowserViewLayout())
///关闭按钮
private lazy var closeButton = UIButton(title: "关闭", fontSize: 14, color: .white, imageName: nil, backColor: .darkGray)
///保存按钮
private lazy var saveButton = UIButton(title: "保存", fontSize: 14, color: .white, imageName: nil, backColor: .darkGray)
//MARK: - 自定义流水布局
private class PhotoBrowserViewLayout: UICollectionViewFlowLayout{
override func prepare() {
super.prepare()
itemSize = collectionView!.bounds.size
minimumLineSpacing = 0
minimumInteritemSpacing = 0
scrollDirection = .horizontal
collectionView?.isPagingEnabled = true
collectionView?.bounces = false
collectionView?.showsHorizontalScrollIndicator = false
}
}
}
//MARK: - 设置 UI
private extension PhotoBrowserController{
//设置UI
private func setupUI(){
//1.添加控件
view.addSubview(collectionView)
view.addSubview(closeButton)
view.addSubview(saveButton)
//2.设置布局
collectionView.frame = view.bounds
collectionView.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
closeButton.snp.makeConstraints { make in
make.bottom.equalTo(view.snp.bottom).offset(-8 - BottomSafeHeight)
make.left.equalTo(view.snp.left).offset(8)
make.size.equalTo(CGSize(width: 100, height: 36))
}
saveButton.snp.makeConstraints { make in
make.bottom.equalTo(view.snp.bottom).offset(-8 - BottomSafeHeight)
make.right.equalTo(view.snp.right).offset(-28)
make.size.equalTo(CGSize(width: 100, height: 36))
}
//3.监听方法
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
saveButton.addTarget(self, action: #selector(save), for: .touchUpInside)
//4.准备控件
prepareCollectionView()
}
/// 准备 CollectionView
private func prepareCollectionView(){
//1.注册可重用 Cell
collectionView.register(PhotoBrowserCell.self, forCellWithReuseIdentifier: PhotoBrowserViewCellId)
//2.设置数据源
collectionView.dataSource = self
}
}
//MARK: - UICollectionViewDataSource
extension PhotoBrowserController: UICollectionViewDataSource{
///数量
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return urls.count
}
///cell
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoBrowserViewCellId, for: indexPath) as! PhotoBrowserCell
cell.imageURL = urls[indexPath.item]
//设置代理
cell.photoDelegate = self
return cell
}
}
//MARK: - PhotoBrowserCellDelegate
extension PhotoBrowserController: PhotoBrowserCellDelegate{
func photoBrowserCellDidTapImage() {
close()
}
}
//MARK: - 解除转场动画协议
extension PhotoBrowserController:PhotoBrowserDismissDelegate{
func imageViewForDismiss() -> UIImageView {
let iv = UIImageView()
//设置填充模式
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
//设置图像 - 直接从当前显示的 cell 中获取
let cell = collectionView.visibleCells[0] as! PhotoBrowserCell
iv.image = cell.imageView.image
//设置位置 - 坐标转换(由父视图进行转换)
iv.frame = cell.scrollView.convert(cell.imageView.frame, to: keyWindow)
//测试代码
return iv
}
func indexPathForDismiss() -> IndexPath {
return collectionView.indexPathsForVisibleItems[0]
}
}
3. 创建 PhotoBrowserCell.swift
//
// PhotoBrowserCell.swift
// Created by Hanyang Li on 2022/7/6.
//
import UIKit
import SDWebImage
import SVProgressHUD
protocol PhotoBrowserCellDelegate: NSObjectProtocol{
func photoBrowserCellDidTapImage()
}
//MARK: - 照片浏览 Cell
class PhotoBrowserCell: UICollectionViewCell {
weak var photoDelegate:PhotoBrowserCellDelegate?
//MARK: - 监听方法
@objc private func tapImage(){
photoDelegate?.photoBrowserCellDidTapImage()
}
/**
手势识别是对 touch 的一个封装,UIScorllView 支持捏合手势,一般做过手势监听的控件,都会屏蔽掉 touch 事件
*/
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("touchesBegan")
}
//MARK: - 图像地址
var imageURL: URL?{
didSet{
guard let url = imageURL else{
return
}
//0.恢复scrollView
resetScrollView()
//1.url 缩略图的地址
//1>从磁盘加载缩略图的图像
let placeHolderImage = SDImageCache.shared.imageFromDiskCache(forKey: url.absoluteString)
setPlaceHolder(image: placeHolderImage)
//2.异步加载大图,sd_webImage 一旦设置了 URL ,准备异步加载
//清除之前加载的图片,如果之前的图片也是异步下载,但没有完成,会取消之前的异步操作
//4>异步加载大图
//几乎所有的第三方框架,进度回调都是异步的
//原因
//1.不是所有的程序都需要进度回调
//2.进度回调的频率非常高,如果是在主线程,会造成主线程的卡顿
//3.使用进度回调,需求界面上跟进进度变化的 UI 不多,而且不会频繁更新
imageView.sd_setImage(with: bmiddleURL(url: url), placeholderImage: placeHolderImage, options: [.retryFailed,.refreshCached]) { current, total, url in
//更新进度
DispatchQueue.main.async {
self.placeHolder.progress = CGFloat(current) / CGFloat(total)
}
} completed: { image, _, _, _ in
//判断图像下载是否成功
if image == nil{
SVProgressHUD.showError(withStatus: "您的网络不给力")
return
}
//隐藏占位图像
self.placeHolder.isHidden = true
//设置图像视图位置
self.setPositon(image: image)
}
}
}
/// 设置占位图像视图的内容
/// - Parameter image: 本地缓存的缩略图,如果缩略图下载失败,image 为nil
private func setPlaceHolder(image: UIImage?){
//显示
placeHolder.isHidden = false
placeHolder.image = image
//2>设置大小
placeHolder.sizeToFit()
//3>设置中心点
placeHolder.center = scrollView.center
}
//重置 ScrollView
private func resetScrollView(){
//重设 imageView 的内容属性 - scrollView 在处理缩放的时候,是调整代理方法返回视图的 transform 来实现的
imageView.transform = .identity
//重设 ScrollView 内容属性
scrollView.contentInset = UIEdgeInsets.zero
scrollView.contentOffset = CGPoint.zero
scrollView.contentSize = CGSize.zero
}
/// 设置 imageView 的位置 长短图的显示
/// - Parameter image: iamge
private func setPositon(image: UIImage?){
guard let image = image else{
return
}
//自动设置大小
let size = self.displaySize(image: image)
//判断图片高度
if size.height < scrollView.bounds.height{
//上下居中显示
self.imageView.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
//内容边距
let y = (scrollView.bounds.height - size.height) * 0.5
scrollView.contentInset = UIEdgeInsets(top: y, left: 0, bottom: 0, right: 0)
}else{
self.imageView.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
scrollView.contentSize = size
}
scrollView.contentInsetAdjustmentBehavior = .never
}
/// 根据 scrollView 的宽度计算等比例缩放之后的图片尺寸
/// - Parameter image: image
/// - Returns: 缩放之后的 size
private func displaySize(image: UIImage) -> CGSize{
let w = scrollView.bounds.width
let h = image.size.height * w / image.size.width
return CGSize(width: w, height: h)
}
/// 返回中等尺寸图片 URL
/// - Parameter url: 缩略图 url
/// - Returns: 中等尺寸 URL
private func bmiddleURL(url: URL) -> URL{
//1.转换成 string
var urlString = url.absoluteString
//2.替换单词 large 大等尺寸图片地址 thumbnail:缩略图片地址 bmiddle:中等尺寸图片地址 original:原始图片地址
urlString = urlString.replacingOccurrences(of: "/thumbnail/", with: "/large/")
//print(urlString)
return URL(string: urlString)!
}
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI(){
//1.添加控件
contentView.addSubview(scrollView)
scrollView.addSubview(imageView)
scrollView.addSubview(placeHolder)
//2.设置位置
var rect = bounds
rect.size.width -= 20
scrollView.frame = rect
//3.设置 scrollView 缩放
scrollView.delegate = self
scrollView.maximumZoomScale = 2.0
scrollView.minimumZoomScale = 0.5
//4.添加手势识别
let tap = UITapGestureRecognizer(target: self, action: #selector(tapImage))
imageView.isUserInteractionEnabled = true
imageView.addGestureRecognizer(tap)
}
//MARK: - 懒加载控件
lazy var scrollView = UIScrollView()
lazy var imageView = UIImageView()
/// 占位图像
private lazy var placeHolder = ProgressImageView()
}
//MARK: - UIScrollViewDelegate
extension PhotoBrowserCell: UIScrollViewDelegate{
///返回被缩放的视图
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
/// 缩放完成执行一次
/// - Parameters:
/// - scrollView: scrollView
/// - view: view 被缩放的视图
/// - scale: 被缩放的比例
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
//print("缩放完成\(view) \(view?.bounds)")
var offsetY = (scrollView.bounds.height - view!.frame.height) * 0.5
offsetY = offsetY < 0 ? 0 : offsetY
var offsetX = (scrollView.bounds.width - view!.frame.width) * 0.5
offsetX = offsetX < 0 ? 0 : offsetX
//设置间距
scrollView.contentInset = UIEdgeInsets(top: offsetY, left: offsetX, bottom: 0, right: 0)
}
/// 只要缩放就会被调用
/**
a d => 缩放比例
a b c d => 共同决定旋转
tx ty => 设置位移
定义控件位置 frame = center + bounds + transforms
*/
func scrollViewDidZoom(_ scrollView: UIScrollView) {
//print(imageView.transform)
}
}
4. 创建 ProgressImageView.swift
//
// ProgressImageView.swift
// Created by Hanyang Li on 2022/7/7.
//
import UIKit
/// 带进度的图像视图 不会执行 drawRect 函数
class ProgressImageView: UIImageView {
//外部传递的进度值 0~1
var progress: CGFloat = 0{
didSet{
progressView.progress = progress
}
}
//一旦给构造函数指定了参数,系统不在提供默认的构造函数,默认的构造函数会被覆盖
init(){
super.init(frame: CGRect.zero)
setupUI()
}
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//设置布局
private func setupUI(){
addSubview(progressView)
progressView.backgroundColor = .clear
//自动布局
progressView.snp.makeConstraints { make in
make.edges.equalTo(self.snp.edges)
}
}
//MARK: - 懒加载控件
private lazy var progressView = ProgressView()
}
//进度视图
private class ProgressView: UIView{
//内部使用的进度值 0~1
var progress: CGFloat = 0{
didSet{
//重绘视图
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
//中心点
let center = CGPoint(x: rect.width * 0.5, y: rect.height * 0.5)
let r = min(rect.width, rect.height) * 0.5
let start = -.pi * 0.5
let end = start + progress * 2 * .pi
/**
参数
1.中心点
2.半径
3.起始弧度
4.结束弧度
5.是否顺时针
*/
let path = UIBezierPath(arcCenter: center, radius: r, startAngle: start, endAngle: end, clockwise: true)
//添加到中心点点连线
path.addLine(to: center)
path.close()
UIColor(white: 1.0, alpha: 0.3).setFill()
path.fill()
}
}
5. 创建 PhotoBrowserAnimator.swift
//
// PhotoBrowserAnimator.swift
// Created by Hanyang Li on 2022/7/14.
//
import UIKit
//MARK: - 展现动画的协议
protocol PhotoBrowserPresentDelegate: NSObjectProtocol{
/// 指定 indexPath 对应的 imageView,用来做动画效果
func imageViewForPresent(indexPath: IndexPath) -> UIImageView
/// 动画转场的起始位置
func photoBrowserPresentFromRect(indexPath: IndexPath) -> CGRect
/// 动画转场的目标位置
func photoBrowserPresentToRect(indexPath: IndexPath) -> CGRect
}
//MARK: - 解除动画协议
protocol PhotoBrowserDismissDelegate: NSObjectProtocol{
/// 解除转场到图像视图 (包含起始位置)
func imageViewForDismiss() -> UIImageView
/// 解除转场的图像索引
func indexPathForDismiss() -> IndexPath
}
//MARK: - 提供动画转场的 "代理"
class PhotoBrowserAnimator: NSObject, UIViewControllerTransitioningDelegate{
//展现代理
weak var presentDelegate: PhotoBrowserPresentDelegate?
//解除代理
weak var dismissDelegate: PhotoBrowserDismissDelegate?
//动画图像的索引
var indexPath:IndexPath?
///是否 modal 展现的标记
private var isPresented = false
/// 设置代理相关属性
/// - Parameters:
/// - presentDelegate: 展现代理对象
/// - indexPath: 图像的索引
func setDelegateParams(presentDelegate:PhotoBrowserPresentDelegate, indexPath: IndexPath, dismissDelegate: PhotoBrowserDismissDelegate){
self.presentDelegate = presentDelegate
self.indexPath = indexPath
self.dismissDelegate = dismissDelegate
}
//返回提供 modal 展现的 ’动画对象‘
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresented = true
return self
}
//返回提供 dismiss 的 ‘动画对象’
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresented = false
return self
}
}
//MARK: - UIViewControllerAnimatedTransitioning
//实现具体的动画时长
extension PhotoBrowserAnimator: UIViewControllerAnimatedTransitioning{
//动画时长
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
/// 实现具体的动画效果 - 一旦实现了此方法,所有的动画代码都交由程序员负责
/// - Parameter transitionContext: 转场动画的上下文,提供动画所需要的素材
/**
1.容器视图 - 会将 Modal 要展现的视图包装在容器视图中
存放的视图要显示 - 必须自己指定大小! 不会通过自动布局填满屏幕
2.transitionContext.viewController: Key.from / to
3.transitionContext.view: Key.from/to
4.completeTransition: 无论转场是否被被取消,都必须调用,否则,系统不做其他事件处理!
*/
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
//自动布局系统不会对根视图做任何约束
//let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)
//print(fromVC)
//let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
///print(toVC)
//let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from)
//print(fromView)
//let toView = transitionContext.view(forKey: UITransitionContextViewKey.to)
//print(toView)
isPresented ? presentAnimation(using: transitionContext) : dismissAnimation(using: transitionContext)
}
//解除转场动画
private func dismissAnimation(using transitionContext: UIViewControllerContextTransitioning){
//判断参数是否存在 guard let 会把属性变成局部变量,后续的闭包中不需要 self,也不需要考虑解包
guard let presentDelegate = presentDelegate, let dismissDelegate = dismissDelegate else{
return
}
//1.获取要 dismiss 的控制器的视图
let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from)!
fromView.removeFromSuperview()
//2.获取图像视图
let iv = dismissDelegate.imageViewForDismiss()
//3.添加到容器视图
transitionContext.containerView.addSubview(iv)
//4.获取 dismiss 的indexPath
let indexPath = dismissDelegate.indexPathForDismiss()
UIView.animate(withDuration: transitionDuration(using: transitionContext)) {
//让 iv 运动到目标位置
iv.frame = presentDelegate.photoBrowserPresentFromRect(indexPath: indexPath)
} completion: { _ in
//将 fromView 从父视图中删除
iv.removeFromSuperview()
//告诉系统,动画已完成
transitionContext.completeTransition(true)
}
}
/// 展现动画
private func presentAnimation(using transitionContext: UIViewControllerContextTransitioning){
//0.判断参数是否存在
guard let presentDelegate = presentDelegate, let indexPath = indexPath else{
return
}
//1.目标视图
//1>.获取 modal 要展现的控制器的根视图
let toView = transitionContext.view(forKey: UITransitionContextViewKey.to)!
// 2> 添加到容器视图
//transitionContext.containerView.addSubview(toView)
// 3> 设置透明度
//toView.alpha = 0
//2.图像视图
//1>.能够拿到参数与动画的图像视图 / / 目标位置
let iv = presentDelegate.imageViewForPresent(indexPath: indexPath)
//2>.制定图像视图位置 /起始位置
iv.frame = presentDelegate.photoBrowserPresentFromRect(indexPath: indexPath)
//3>.将视图添加到容器中
transitionContext.containerView.addSubview(iv)
//3.开始动画
UIView.animate(withDuration: transitionDuration(using: transitionContext)) {
iv.frame = presentDelegate.photoBrowserPresentToRect(indexPath: indexPath)
//toView.alpha = 1.0
} completion: { _ in
//图像视图删除
iv.removeFromSuperview()
transitionContext.containerView.addSubview(toView)
//4.告诉系统转场动画完成
transitionContext.completeTransition(true)
}
}
}
6. 照片查看器的展现协议
//MARK: - 照片查看器的展现协议
extension StatusPictureView: PhotoBrowserPresentDelegate{
//创建一个 ImageView 在参与动画
func imageViewForPresent(indexPath: IndexPath) -> UIImageView {
let iv = UIImageView()
//1.设置内容填充模式
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
//2.设置图像 (缩略图的缓存) - SDWebImage 如果已经存在本地缓存,不会发起网络请求
if let url = viewModel?.thumbnailUrls?[indexPath.item]{
iv.sd_setImage(with: url)
}
return iv
}
///动画的起始位置
func photoBrowserPresentFromRect(indexPath: IndexPath) -> CGRect {
//1.根据 indexPath 获得当前用户选择的 cell
let cell = self.cellForItem(at: indexPath)!
//2.通过 cell 知道 cell 对应在屏幕上的准确位置
//在不同视图之间的 ‘坐标系的转换’ self 是 cell 的父视图
//由 collectionView 将 cell 的 frame 位置转换的 keyWindow 对应的 frame 位置
let rect = self.convert(cell.frame, to: keyWindow)
return rect
}
///动画的目标位置
func photoBrowserPresentToRect(indexPath: IndexPath) -> CGRect {
//根据缩略图的大小,等比例计算目标位置
guard let key = viewModel?.thumbnailUrls?[indexPath.item].absoluteString else{
return .zero
}
//从 sdwebImage 获取本地缓存图片
guard let image = SDImageCache.shared.imageFromDiskCache(forKey: key) else{
return .zero
}
//根据图像大小,计算全屏的大小
let w = screenWidth
let h = image.size.height * w / image.size.width
//对高度进行额外的处理
var y:CGFloat = 0
if h < screenHeight { //图片短,垂直居中显示
y = (screenHeight - h) * 0.5
}
return CGRect(x: 0, y: y, width: w, height: h)
}
}
7. 调用方法
//[weak self] 弱引用
guard let indexPath = notification.userInfo?[StatusSelectPhotoIndexPathKey] as? IndexPath else{
return
}
guard let urls = notification.userInfo?[StatusSelectPhotoURLsKey] as? [URL] else{
return
}
//判断 cell 是否遵守了展现动画协议
guard let cell = notification.object as? PhotoBrowserPresentDelegate else{
return
}
//Modal 展现
let vc = PhotoBrowserController(urls: urls, indexPath: indexPath)
vc.modalPresentationStyle = .fullScreen
//1.设置 modal 的类型是自定义类型 Transition (转场)
vc.modalPresentationStyle = .custom
//2.设置动画代理
vc.transitioningDelegate = self?.photoBrowserAnimator
//3.设置 animator 的代理参数
self?.photoBrowserAnimator.setDelegateParams(presentDelegate: cell, indexPath: indexPath, dismissDelegate: vc)
//参数设置所有权交给调用方 (一旦调用方失误漏传参数,可能造成不必要的麻烦)
//会有一系列的 ...
//self?.photoBrowserAnimator.presentDelegate = cell
//self?.photoBrowserAnimator.indexPath = indexPath
//self?.photoBrowserAnimator.dismissDelegate = vc
//4. model 展现
self?.present(vc, animated: true, completion: nil)
8. 效果图片