# PhotoBrowser **Repository Path**: murat/PhotoBrowser ## Basic Information - **Project Name**: PhotoBrowser - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-10-30 - **Last Updated**: 2026-04-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # JXPhotoBrowser [![CocoaPods](https://img.shields.io/cocoapods/v/JXPhotoBrowser.svg)](https://cocoapods.org/pods/JXPhotoBrowser) [![SPM Supported](https://img.shields.io/badge/SPM-supported-brightgreen)](https://swift.org/package-manager/) [![Carthage Compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg)](https://github.com/Carthage/Carthage) [![Platform](https://img.shields.io/cocoapods/p/JXPhotoBrowser.svg)](https://cocoapods.org/pods/JXPhotoBrowser) [![License](https://img.shields.io/github/license/JiongXing/PhotoBrowser)](LICENSE) [English Documentation](README_EN.md) JXPhotoBrowser 是一个轻量级、可定制的 iOS 图片/视频浏览器,实现 iOS 系统相册的交互体验。支持缩放、拖拽关闭、自定义转场动画等特性,架构清晰,易于集成和扩展。同时支持 **UIKit** 和 **SwiftUI** 两种调用方式(SwiftUI 通过桥接层集成,详见 Demo-SwiftUI 示例工程)。 详细技术方案与实现说明请参阅 [TECHNICAL_SOLUTION.md](TECHNICAL_SOLUTION.md)。 | 首页列表 | 图片浏览 | 下拉关闭 | | :---: | :---: | :---: | | ![首页列表](readme_assets/homepage.png) | ![图片浏览](readme_assets/browsing.png) | ![下拉关闭](readme_assets/pull_down.png) | ## 核心设计 - **零数据模型依赖**:框架不定义任何数据模型,业务方完全使用自己的数据结构,通过 delegate 配置 Cell 内容。 - **图片加载完全开放**:框架不内置图片加载逻辑,业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。 - **极简 Cell 协议**:`JXPhotoBrowserCellProtocol` 仅包含 `browser` 和 `transitionImageView` 两个属性,将浏览器与具体 Cell 实现解耦,既可以直接使用内置的 `JXZoomImageCell`,也可以实现完全自定义的 Cell。 - **协议驱动的数据与 UI 解耦**:`JXPhotoBrowserDelegate` 只关心数量、Cell 与转场,不强制统一的数据模型。 ## 功能特性 - **多模式浏览**:支持水平(Horizontal)和垂直(Vertical)两个方向的滚动浏览。 - **无限循环**:支持无限循环滚动(Looping),无缝切换首尾图片。 - **手势交互**: - **双击缩放**:仿系统相册支持双击切换缩放模式。 - **捏合缩放**:支持双指捏合随意缩放(1.0x - 3.0x)。 - **拖拽关闭**:支持下滑手势(Pan)交互式关闭,伴随图片缩小和背景渐变效果。为避免与放大后的内容滚动冲突,仅在图片回到最小缩放时触发。 - **转场动画**: - **Fade**:经典的渐隐渐现效果。 - **Zoom**:类似微信/系统相册的缩放转场效果,无缝衔接列表与大图。 - **None**:无动画直接显示。 - **浏览体验优化**:基于 `UICollectionView` 复用机制,内存占用低,滑动流畅。 - **自定义 Cell 支持**:内置图片 `JXZoomImageCell`,也支持通过协议与注册机制接入完全自定义的 Cell(如视频播放 Cell)。 - **Overlay 组件机制**:支持按需装载附加 UI 组件(如页码指示器、关闭按钮等),默认不装载任何组件,零开销。内置 `JXPageIndicatorOverlay` 页码指示器。 ## 核心架构 - **JXPhotoBrowserViewController**:核心控制器,继承自 `UIViewController`。内部维护一个 `UICollectionView` 用于展示图片页面,负责处理全局配置(如滚动方向、循环模式)和手势交互(如下滑关闭)。 - **JXZoomImageCell**:可缩放图片展示单元,继承自 `UICollectionViewCell` 并实现 `JXPhotoBrowserCellProtocol`。内部使用 `UIScrollView` 实现缩放,负责单击、双击等交互。通过 `imageView` 属性供业务方设置图片。 - **JXImageCell**:轻量级图片展示 Cell,不支持缩放手势,适用于 Banner 等嵌入式场景。内置可选的加载指示器(默认不启用),支持样式定制。 - **JXPhotoBrowserCellProtocol**:极简 Cell 协议,仅需 `browser`(弱引用浏览器)和 `transitionImageView`(转场视图)两个属性即可接入浏览器,另提供 `photoBrowserDismissInteractionDidChange` 可选方法响应下拉关闭交互,不强制依赖特定基类。 - **JXPhotoBrowserDelegate**:代理协议,负责提供总数、Cell 实例、生命周期回调(`willDisplay`/`didEndDisplaying`)以及转场动画所需的缩略图视图等,不强制要求统一的数据模型。 - **JXPhotoBrowserOverlay**:附加视图组件协议,定义了 `setup`、`reloadData`、`didChangedPageIndex` 三个方法,用于页码指示器、关闭按钮等附加 UI 的统一接入。 - **JXPageIndicatorOverlay**:内置页码指示器组件,基于 `UIPageControl`,支持自定义位置和样式,通过 `addOverlay` 按需装载。 ## 依赖 - 框架本身依赖:`UIKit`(核心),**无任何第三方依赖**。 - 图片加载:框架不内置图片加载逻辑,业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。 - 示例工程: - **Demo-UIKit**:UIKit 示例,使用 CocoaPods 集成,依赖 `Kingfisher` 加载图片,演示完整功能(图片浏览、视频播放、Banner 轮播等)。 - **Demo-SwiftUI**:SwiftUI 示例,使用 SPM 集成,演示如何通过桥接层在 SwiftUI 中使用 JXPhotoBrowser(媒体网格、设置面板、图片浏览)。 - **Demo-Carthage**:UIKit 示例,使用 Carthage 集成。首次使用需在 `Demo-Carthage` 目录下执行 `carthage update --use-xcframeworks --platform iOS` 构建框架。 ## 隐私清单(Privacy Manifest) 本框架已包含 `PrivacyInfo.xcprivacy` 隐私清单文件,符合 Apple 自 2024 年春季起对第三方 SDK 的隐私清单要求。 JXPhotoBrowser **不追踪用户、不收集任何数据、不使用任何 Required Reason API**,隐私清单中所有字段均为空声明。通过 CocoaPods、SPM 或 Carthage 集成时,隐私清单会自动包含在框架中,无需额外配置。 ## 系统要求 - iOS 12.0+ - Swift 5.4+ ## 安装 ### CocoaPods 在你的 `Podfile` 中添加: ```ruby pod 'JXPhotoBrowser', '~> 4.0.3' ``` > **注意**:Xcode 15 起默认开启了 **User Script Sandboxing**(`ENABLE_USER_SCRIPT_SANDBOXING=YES`),该沙盒机制会阻止 CocoaPods 的 Run Script 阶段(如 `[CP] Copy Pods Resources`、`[CP] Embed Pods Frameworks` 等)访问沙盒外的文件,导致编译失败。需要在编译 Target 的 **Build Settings** 中将 `ENABLE_USER_SCRIPT_SANDBOXING` 设置为 `NO`: > > **Target → Build Settings → Build Options → User Script Sandboxing → No** ### Swift Package Manager 在 Xcode 中: 1. 选择 **File > Add Package Dependencies...** 2. 输入仓库地址:`https://github.com/JiongXing/PhotoBrowser` 3. 选择版本规则后点击 **Add Package** 或在 `Package.swift` 中添加依赖: ```swift dependencies: [ .package(url: "https://github.com/JiongXing/PhotoBrowser", from: "4.0.3") ] ``` ### Carthage 在你的 `Cartfile` 中添加: ``` github "JiongXing/PhotoBrowser" ``` 然后运行: ```bash carthage update --use-xcframeworks --platform iOS ``` 构建完成后,将 `Carthage/Build/JXPhotoBrowser.xcframework` 拖入 Xcode 工程的 **Frameworks, Libraries, and Embedded Content** 中,并设置为 **Embed & Sign**。 ### 手动安装 将 `Sources` 目录下的所有文件拖入你的工程中。 ## 快速开始 ### 基础用法 ```swift import JXPhotoBrowser // 1. 创建浏览器实例 let browser = JXPhotoBrowserViewController() browser.delegate = self browser.initialIndex = indexPath.item // 设置初始索引 // 2. 配置选项(可选) browser.scrollDirection = .horizontal // 滚动方向 browser.transitionType = .zoom // 转场动画类型 browser.isLoopingEnabled = true // 是否开启无限循环 // 3. 展示 browser.present(from: self) ``` ### 实现 Delegate 遵守 `JXPhotoBrowserDelegate` 协议,提供数据和转场支持: ```swift import Kingfisher // 示例使用 Kingfisher,可替换为任意图片加载库 extension ViewController: JXPhotoBrowserDelegate { // 1. 返回图片总数 func numberOfItems(in browser: JXPhotoBrowserViewController) -> Int { return items.count } // 2. 提供用于展示的 Cell func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell { let cell = browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell return cell } // 3. 当 Cell 将要显示时加载图片 func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) { guard let photoCell = cell as? JXZoomImageCell else { return } let item = items[index] // 使用 Kingfisher 加载图片(可替换为 SDWebImage 或其他库) let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: item.thumbnailURL.absoluteString) photoCell.imageView.kf.setImage(with: item.originalURL, placeholder: placeholder) { [weak photoCell] _ in photoCell?.setNeedsLayout() } } // 4. (可选) Cell 结束显示时清理资源(如取消加载、停止播放等) func photoBrowser(_ browser: JXPhotoBrowserViewController, didEndDisplaying cell: JXPhotoBrowserAnyCell, at index: Int) { // 可用于取消图片加载、停止视频播放等 } // 5. (可选) 支持 Zoom 转场:提供列表中的缩略图视图 func photoBrowser(_ browser: JXPhotoBrowserViewController, thumbnailViewAt index: Int) -> UIView? { let indexPath = IndexPath(item: index, section: 0) guard let cell = collectionView.cellForItem(at: indexPath) as? MyCell else { return nil } return cell.imageView } // 6. (可选) 控制缩略图显隐,避免 Zoom 转场时视觉重叠 func photoBrowser(_ browser: JXPhotoBrowserViewController, setThumbnailHidden hidden: Bool, at index: Int) { let indexPath = IndexPath(item: index, section: 0) if let cell = collectionView.cellForItem(at: indexPath) as? MyCell { cell.imageView.isHidden = hidden } } // 7. (可选) 自定义 Cell 尺寸,默认使用浏览器全屏尺寸 func photoBrowser(_ browser: JXPhotoBrowserViewController, sizeForItemAt index: Int) -> CGSize? { return nil // 返回 nil 使用默认尺寸 } } ``` ## 在 SwiftUI 中使用 JXPhotoBrowser 是基于 UIKit 的框架,在 SwiftUI 项目中可通过桥接方式集成。Demo-SwiftUI 示例工程演示了完整的集成方案。 ### 核心思路 1. **网格和设置面板**使用纯 SwiftUI 实现(`LazyVGrid`、`Picker`、`AsyncImage` 等) 2. **全屏图片浏览器**通过桥接层调用 `JXPhotoBrowserViewController` 3. 创建一个 Presenter 类实现 `JXPhotoBrowserDelegate`,获取当前 `UIViewController` 后调用 `browser.present(from:)` ### 桥接层示例 ```swift import JXPhotoBrowser /// 封装 JXPhotoBrowserViewController 的创建、配置和呈现 final class PhotoBrowserPresenter: JXPhotoBrowserDelegate { private let items: [MyMediaItem] func present(initialIndex: Int) { guard let viewController = topViewController() else { return } let browser = JXPhotoBrowserViewController() browser.delegate = self browser.initialIndex = initialIndex browser.transitionType = .fade browser.addOverlay(JXPageIndicatorOverlay()) browser.present(from: viewController) } func numberOfItems(in browser: JXPhotoBrowserViewController) -> Int { items.count } func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell { browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell } func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) { guard let photoCell = cell as? JXZoomImageCell else { return } // 加载图片到 photoCell.imageView ... } } ``` ### 在 SwiftUI View 中调用 ```swift struct ContentView: View { // 持有 presenter(JXPhotoBrowserViewController.delegate 为 weak,需要外部强引用) @State private var presenter: PhotoBrowserPresenter? var body: some View { LazyVGrid(columns: columns) { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in AsyncImage(url: item.thumbnailURL) .onTapGesture { let p = PhotoBrowserPresenter(items: items) presenter = p p.present(initialIndex: index) } } } } } ``` > **注意**:`JXPhotoBrowserViewController` 的 `delegate` 是 `weak` 引用,必须在 SwiftUI 侧用 `@State` 持有 Presenter 实例,否则它会在创建后立即被释放。 ### 关于 Zoom 转场 Demo-SwiftUI 示例工程未演示 Zoom 转场动画,默认使用 Fade 转场。 **原因**:Zoom 转场依赖 `thumbnailViewAt` delegate 方法返回列表中缩略图的 `UIView` 引用,框架通过该引用计算动画起止位置并构建临时动画视图。而 SwiftUI 的 `AsyncImage` 等原生视图无法直接提供底层 `UIView` 引用。 **如需自行实现**:可将缩略图从 `AsyncImage` 替换为 `UIViewRepresentable` 包裹的 `UIImageView`,从而获取真实的 `UIView` 引用,再通过 `thumbnailViewAt` 和 `setThumbnailHidden` 两个 delegate 方法提供给框架即可。具体的 Zoom 转场接入方式可参考 Demo-UIKit 示例工程。 ## JXImageCell 加载指示器 `JXImageCell` 内置了一个 `UIActivityIndicatorView` 加载指示器,**默认不启用**。适用于 Banner 等嵌入式场景下展示图片加载状态。 ### 启用加载指示器 ```swift let cell = browser.dequeueReusableCell(withReuseIdentifier: JXImageCell.reuseIdentifier, for: indexPath) as! JXImageCell // 启用加载指示器 cell.isLoadingIndicatorEnabled = true cell.startLoading() // 图片加载完成后停止 cell.imageView.kf.setImage(with: imageURL) { [weak cell] _ in cell?.stopLoading() } ``` ### 自定义样式 通过 `loadingIndicator` 属性可直接定制指示器的外观: ```swift cell.loadingIndicator.style = .large // 指示器尺寸 cell.loadingIndicator.color = .systemBlue // 指示器颜色 ``` ## 自定义 Cell 框架支持两种方式创建自定义 Cell: ### 方式一:继承 JXZoomImageCell(推荐) 继承 `JXZoomImageCell` 可自动获得缩放、转场、手势等功能。以 Demo 中的 `VideoPlayerCell` 为例,它继承 `JXZoomImageCell` 并添加了视频播放能力: ```swift class VideoPlayerCell: JXZoomImageCell { static let videoReuseIdentifier = "VideoPlayerCell" private var player: AVPlayer? private var playerLayer: AVPlayerLayer? override init(frame: CGRect) { super.init(frame: frame) // 自定义初始化:添加 loading 指示器等 } /// 配置视频资源 func configure(videoURL: URL, coverImage: UIImage? = nil) { imageView.image = coverImage // 创建播放器并开始播放... } /// 重写单击手势:暂停视频或关闭浏览器 override func handleSingleTap(_ gesture: UITapGestureRecognizer) { if isPlaying { pauseVideo() } else { browser?.dismissSelf() } } } ``` ### 方式二:实现协议(完全自定义) 直接实现 `JXPhotoBrowserCellProtocol` 协议,获得完全的自由度: ```swift class StandaloneCell: UICollectionViewCell, JXPhotoBrowserCellProtocol { static let reuseIdentifier = "StandaloneCell" // 必须实现:弱引用浏览器(避免循环引用) weak var browser: JXPhotoBrowserViewController? // 可选实现:用于 Zoom 转场动画,返回 nil 则使用 Fade 动画 var transitionImageView: UIImageView? { imageView } let imageView = UIImageView() override init(frame: CGRect) { super.init(frame: frame) // 自定义初始化 } // 可选实现:下拉关闭交互状态变化时调用 // isInteracting 为 true 表示用户正在下拉(图片缩小跟随手指),false 表示交互结束(回弹恢复) // 下拉关闭仅会在图片处于最小缩放状态时触发 // 适用于在拖拽关闭过程中暂停视频、隐藏附加 UI 等场景 func photoBrowserDismissInteractionDidChange(isInteracting: Bool) { // 例如:下拉时暂停视频播放 } } ``` ### 注册和使用自定义 Cell ```swift let browser = JXPhotoBrowserViewController() // 注册自定义 Cell(必须在设置 delegate 之前) browser.register(VideoPlayerCell.self, forReuseIdentifier: VideoPlayerCell.videoReuseIdentifier) browser.delegate = self browser.present(from: self) // 在 delegate 中使用 func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell { let cell = browser.dequeueReusableCell(withReuseIdentifier: VideoPlayerCell.videoReuseIdentifier, for: indexPath) as! VideoPlayerCell cell.configure(videoURL: url, coverImage: thumbnail) return cell } ``` ## Overlay 组件 框架提供了通用的 Overlay 组件机制,用于在浏览器上层叠加附加 UI(如页码指示器、关闭按钮、标题栏等)。**默认不装载任何 Overlay,业务方按需装载**。 ### 使用内置页码指示器 框架内置了 `JXPageIndicatorOverlay`(基于 `UIPageControl`),一行代码即可装载: ```swift let browser = JXPhotoBrowserViewController() browser.addOverlay(JXPageIndicatorOverlay()) ``` 支持自定义位置和样式: ```swift let indicator = JXPageIndicatorOverlay() indicator.position = .bottom(padding: 20) // 位置:底部距离 20pt(也支持 .top) indicator.hidesForSinglePage = true // 仅一页时自动隐藏 indicator.pageControl.currentPageIndicatorTintColor = .white indicator.pageControl.pageIndicatorTintColor = .lightGray browser.addOverlay(indicator) ``` ### 自定义 Overlay 实现 `JXPhotoBrowserOverlay` 协议即可创建自定义组件: ```swift class CloseButtonOverlay: UIView, JXPhotoBrowserOverlay { func setup(with browser: JXPhotoBrowserViewController) { // 在此完成布局(如添加约束) } func reloadData(numberOfItems: Int, pageIndex: Int) { // 数据或布局变化时更新 } func didChangedPageIndex(_ index: Int) { // 页码变化时更新 } } // 装载 browser.addOverlay(CloseButtonOverlay()) ``` 多个 Overlay 可同时装载,互不干扰: ```swift browser.addOverlay(JXPageIndicatorOverlay()) browser.addOverlay(CloseButtonOverlay()) ``` ## 保存图片/视频到相册 框架本身不内置保存功能,业务方可自行实现。Demo 中演示了通过长按手势弹出 ActionSheet 保存媒体到系统相册的完整流程。 > **前提**:需要在 `Info.plist` 中配置 `NSPhotoLibraryAddUsageDescription`(写入相册权限描述)。 ### 核心步骤 1. **添加长按手势**:在自定义 Cell 中添加 `UILongPressGestureRecognizer`。 2. **弹出 ActionSheet**:通过 `browser` 属性获取浏览器控制器来 present。 3. **请求权限并保存**:使用 `PHPhotoLibrary` 请求权限,下载后写入相册。 ### 示例:在自定义 Cell 中长按保存 以 Demo 中的 `VideoPlayerCell` 为例,继承 `JXZoomImageCell` 后添加长按保存能力: ```swift import Photos class VideoPlayerCell: JXZoomImageCell { override init(frame: CGRect) { super.init(frame: frame) // 添加长按手势 let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) scrollView.addGestureRecognizer(longPress) } @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { guard gesture.state == .began else { return } let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: "保存视频", style: .default) { [weak self] _ in self?.saveVideoToAlbum() }) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) // 通过 browser 属性获取浏览器控制器来 present browser?.present(alert, animated: true) } private func saveVideoToAlbum() { guard let url = videoURL else { return } // 1. 请求相册写入权限 PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in guard status == .authorized || status == .limited else { return } // 2. 下载视频(远程 URL 需先下载到本地) URLSession.shared.downloadTask(with: url) { tempURL, _, _ in guard let tempURL else { return } // 3. 写入相册 PHPhotoLibrary.shared().performChanges({ PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tempURL) }) { success, error in // 处理结果... } }.resume() } } } ``` 保存图片的流程类似,将下载部分替换为图片写入即可: ```swift // 下载图片数据 URLSession.shared.dataTask(with: imageURL) { data, _, _ in guard let data, let image = UIImage(data: data) else { return } PHPhotoLibrary.shared().performChanges({ PHAssetChangeRequest.creationRequestForAsset(from: image) }) { success, error in // 处理结果... } }.resume() ``` ## 常见问题 (FAQ) ### Q: Zoom 转场动画时图片尺寸不对或有闪烁现象? **A**: 这通常是因为打开浏览器时,目标 Cell 的 `imageView` 还没有设置图片,导致其 `bounds` 为 zero。 **解决方案**:在 `willDisplay` 代理方法中,确保同步设置占位图。例如使用 Kingfisher 时: ```swift func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) { guard let photoCell = cell as? JXZoomImageCell else { return } // 同步从缓存取出缩略图作为占位图 let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: thumbnailURL.absoluteString) photoCell.imageView.kf.setImage(with: imageURL, placeholder: placeholder) { [weak photoCell] _ in photoCell?.setNeedsLayout() } } ``` 这样可以确保转场动画开始时,Cell 已经有正确尺寸的图片,动画效果更加流畅。 ## 版本更新 **v4.0.3**(2026/03/27)— 修复图片首屏居中问题,重构 `JXZoomImageCell` 的布局与居中逻辑,优化下拉关闭交互与裁剪表现。 **v4.0.2**(2026/02/16)— 优化双击放大体验:以点击位置为锚点放大,优化缩小动画流畅度。 **v4.0.1**(2026/02/10)— 全面重构,回归 `UICollectionView`,支持无限循环滚动,新增 SwiftUI 示例。不兼容 3.x 版本。 完整更新记录请查看 [CHANGELOG](CHANGELOG.md)。 ## License 本项目基于 MIT 协议开源。