{"id":15029250,"url":"https://github.com/jiongxing/photobrowser","last_synced_at":"2026-02-16T07:18:42.554Z","repository":{"id":41579674,"uuid":"87784593","full_name":"JiongXing/PhotoBrowser","owner":"JiongXing","description":" Elegant photo browser in Swift. 图片与视频浏览器。","archived":false,"fork":false,"pushed_at":"2024-02-18T09:52:04.000Z","size":29686,"stargazers_count":1331,"open_issues_count":34,"forks_count":211,"subscribers_count":14,"default_branch":"master","last_synced_at":"2025-04-07T06:02:26.628Z","etag":null,"topics":["browser","gesture","image","photo","photo-browser","previewer","swift","transition-animation"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/JiongXing.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2017-04-10T08:14:28.000Z","updated_at":"2025-04-03T05:47:10.000Z","dependencies_parsed_at":"2024-06-18T12:33:41.178Z","dependency_job_id":"e6a5d61f-85b9-4a18-aecc-28ed9e5933ad","html_url":"https://github.com/JiongXing/PhotoBrowser","commit_stats":{"total_commits":370,"total_committers":21,"mean_commits":17.61904761904762,"dds":0.6,"last_synced_commit":"69c5212485b54eeb0eaaa5f77dde1ebb787a51d0"},"previous_names":[],"tags_count":96,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JiongXing%2FPhotoBrowser","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JiongXing%2FPhotoBrowser/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JiongXing%2FPhotoBrowser/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JiongXing%2FPhotoBrowser/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/JiongXing","download_url":"https://codeload.github.com/JiongXing/PhotoBrowser/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248911924,"owners_count":21182176,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["browser","gesture","image","photo","photo-browser","previewer","swift","transition-animation"],"created_at":"2024-09-24T20:10:07.303Z","updated_at":"2026-02-16T07:18:42.547Z","avatar_url":"https://github.com/JiongXing.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# JXPhotoBrowser\n\n[![GitHub Release](https://img.shields.io/github/v/release/JiongXing/PhotoBrowser)](https://github.com/JiongXing/PhotoBrowser/releases) [![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)\n\n[English Documentation](README_EN.md)\n\nJXPhotoBrowser 是一个轻量级、可定制的 iOS 图片/视频浏览器，实现 iOS 系统相册的交互体验。支持缩放、拖拽关闭、自定义转场动画等特性，架构清晰，易于集成和扩展。同时支持 **UIKit** 和 **SwiftUI** 两种调用方式（SwiftUI 通过桥接层集成，详见 Demo-SwiftUI 示例工程）。\n\n| 首页列表 | 图片浏览 | 下拉关闭 |\n| :---: | :---: | :---: |\n| ![首页列表](readme_assets/homepage.png) | ![图片浏览](readme_assets/browsing.png) | ![下拉关闭](readme_assets/pull_down.png) |\n\n## 核心设计\n\n- **零数据模型依赖**：框架不定义任何数据模型，业务方完全使用自己的数据结构，通过 delegate 配置 Cell 内容。\n- **图片加载完全开放**：框架不内置图片加载逻辑，业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。\n- **极简 Cell 协议**：`JXPhotoBrowserCellProtocol` 仅包含 `browser` 和 `transitionImageView` 两个属性，将浏览器与具体 Cell 实现解耦，既可以直接使用内置的 `JXZoomImageCell`，也可以实现完全自定义的 Cell。\n- **协议驱动的数据与 UI 解耦**：`JXPhotoBrowserDelegate` 只关心数量、Cell 与转场，不强制统一的数据模型。\n\n## 功能特性\n\n- **多模式浏览**：支持水平（Horizontal）和垂直（Vertical）两个方向的滚动浏览。\n- **无限循环**：支持无限循环滚动（Looping），无缝切换首尾图片。\n- **手势交互**：\n  - **双击缩放**：仿系统相册支持双击切换缩放模式。\n  - **捏合缩放**：支持双指捏合随意缩放（1.0x - 3.0x）。\n  - **拖拽关闭**：支持下滑手势（Pan）交互式关闭，伴随图片缩小和背景渐变效果。\n- **转场动画**：\n  - **Fade**：经典的渐隐渐现效果。\n  - **Zoom**：类似微信/系统相册的缩放转场效果，无缝衔接列表与大图。\n  - **None**：无动画直接显示。\n- **浏览体验优化**：基于 `UICollectionView` 复用机制，内存占用低，滑动流畅。\n- **自定义 Cell 支持**：内置图片 `JXZoomImageCell`，也支持通过协议与注册机制接入完全自定义的 Cell（如视频播放 Cell）。\n- **Overlay 组件机制**：支持按需装载附加 UI 组件（如页码指示器、关闭按钮等），默认不装载任何组件，零开销。内置 `JXPageIndicatorOverlay` 页码指示器。\n\n## 核心架构\n\n- **JXPhotoBrowserViewController**：核心控制器，继承自 `UIViewController`。内部维护一个 `UICollectionView` 用于展示图片页面，负责处理全局配置（如滚动方向、循环模式）和手势交互（如下滑关闭）。\n- **JXZoomImageCell**：可缩放图片展示单元，继承自 `UICollectionViewCell` 并实现 `JXPhotoBrowserCellProtocol`。内部使用 `UIScrollView` 实现缩放，负责单击、双击等交互。通过 `imageView` 属性供业务方设置图片。\n- **JXImageCell**：轻量级图片展示 Cell，不支持缩放手势，适用于 Banner 等嵌入式场景。内置可选的加载指示器（默认不启用），支持样式定制。\n- **JXPhotoBrowserCellProtocol**：极简 Cell 协议，仅需 `browser`（弱引用浏览器）和 `transitionImageView`（转场视图）两个属性即可接入浏览器，另提供 `photoBrowserDismissInteractionDidChange` 可选方法响应下拉关闭交互，不强制依赖特定基类。\n- **JXPhotoBrowserDelegate**：代理协议，负责提供总数、Cell 实例、生命周期回调（`willDisplay`/`didEndDisplaying`）以及转场动画所需的缩略图视图等，不强制要求统一的数据模型。\n- **JXPhotoBrowserOverlay**：附加视图组件协议，定义了 `setup`、`reloadData`、`didChangedPageIndex` 三个方法，用于页码指示器、关闭按钮等附加 UI 的统一接入。\n- **JXPageIndicatorOverlay**：内置页码指示器组件，基于 `UIPageControl`，支持自定义位置和样式，通过 `addOverlay` 按需装载。\n\n## 依赖\n\n- 框架本身依赖：`UIKit`（核心），**无任何第三方依赖**。\n- 图片加载：框架不内置图片加载逻辑，业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。\n- 示例工程：\n  - **Demo-UIKit**：UIKit 示例，使用 CocoaPods 集成，依赖 `Kingfisher` 加载图片，演示完整功能（图片浏览、视频播放、Banner 轮播等）。\n  - **Demo-SwiftUI**：SwiftUI 示例，使用 SPM 集成，演示如何通过桥接层在 SwiftUI 中使用 JXPhotoBrowser（媒体网格、设置面板、图片浏览）。\n  - **Demo-Carthage**：UIKit 示例，使用 Carthage 集成。首次使用需在 `Demo-Carthage` 目录下执行 `carthage update --use-xcframeworks --platform iOS` 构建框架。\n\n## 隐私清单（Privacy Manifest）\n\n本框架已包含 `PrivacyInfo.xcprivacy` 隐私清单文件，符合 Apple 自 2024 年春季起对第三方 SDK 的隐私清单要求。\n\nJXPhotoBrowser **不追踪用户、不收集任何数据、不使用任何 Required Reason API**，隐私清单中所有字段均为空声明。通过 CocoaPods、SPM 或 Carthage 集成时，隐私清单会自动包含在框架中，无需额外配置。\n\n## 系统要求\n\n- iOS 12.0+\n- Swift 5.4+\n\n## 安装\n\n### CocoaPods\n\n在你的 `Podfile` 中添加：\n\n```ruby\npod 'JXPhotoBrowser', '~\u003e 4.0.2'\n```\n\n\u003e **注意**：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`：\n\u003e\n\u003e **Target → Build Settings → Build Options → User Script Sandboxing → No**\n\n### Swift Package Manager\n\n在 Xcode 中：\n\n1. 选择 **File \u003e Add Package Dependencies...**\n2. 输入仓库地址：`https://github.com/JiongXing/PhotoBrowser`\n3. 选择版本规则后点击 **Add Package**\n\n或在 `Package.swift` 中添加依赖：\n\n```swift\ndependencies: [\n    .package(url: \"https://github.com/JiongXing/PhotoBrowser\", from: \"4.0.2\")\n]\n```\n\n### Carthage\n\n在你的 `Cartfile` 中添加：\n\n```\ngithub \"JiongXing/PhotoBrowser\"\n```\n\n然后运行：\n\n```bash\ncarthage update --use-xcframeworks --platform iOS\n```\n\n构建完成后，将 `Carthage/Build/JXPhotoBrowser.xcframework` 拖入 Xcode 工程的 **Frameworks, Libraries, and Embedded Content** 中，并设置为 **Embed \u0026 Sign**。\n\n### 手动安装\n\n将 `Sources` 目录下的所有文件拖入你的工程中。\n\n## 快速开始\n\n### 基础用法\n\n```swift\nimport JXPhotoBrowser\n\n// 1. 创建浏览器实例\nlet browser = JXPhotoBrowserViewController()\nbrowser.delegate = self\nbrowser.initialIndex = indexPath.item // 设置初始索引\n\n// 2. 配置选项（可选）\nbrowser.scrollDirection = .horizontal // 滚动方向\nbrowser.transitionType = .zoom        // 转场动画类型\nbrowser.isLoopingEnabled = true       // 是否开启无限循环\n\n// 3. 展示\nbrowser.present(from: self)\n```\n\n### 实现 Delegate\n\n遵守 `JXPhotoBrowserDelegate` 协议，提供数据和转场支持：\n\n```swift\nimport Kingfisher // 示例使用 Kingfisher，可替换为任意图片加载库\n\nextension ViewController: JXPhotoBrowserDelegate {\n    // 1. 返回图片总数\n    func numberOfItems(in browser: JXPhotoBrowserViewController) -\u003e Int {\n        return items.count\n    }\n    \n    // 2. 提供用于展示的 Cell\n    func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -\u003e JXPhotoBrowserAnyCell {\n        let cell = browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell\n        return cell\n    }\n    \n    // 3. 当 Cell 将要显示时加载图片\n    func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {\n        guard let photoCell = cell as? JXZoomImageCell else { return }\n        let item = items[index]\n        \n        // 使用 Kingfisher 加载图片（可替换为 SDWebImage 或其他库）\n        let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: item.thumbnailURL.absoluteString)\n        photoCell.imageView.kf.setImage(with: item.originalURL, placeholder: placeholder) { [weak photoCell] _ in\n            photoCell?.setNeedsLayout()\n        }\n    }\n    \n    // 4. (可选) Cell 结束显示时清理资源（如取消加载、停止播放等）\n    func photoBrowser(_ browser: JXPhotoBrowserViewController, didEndDisplaying cell: JXPhotoBrowserAnyCell, at index: Int) {\n        // 可用于取消图片加载、停止视频播放等\n    }\n    \n    // 5. (可选) 支持 Zoom 转场：提供列表中的缩略图视图\n    func photoBrowser(_ browser: JXPhotoBrowserViewController, thumbnailViewAt index: Int) -\u003e UIView? {\n        let indexPath = IndexPath(item: index, section: 0)\n        guard let cell = collectionView.cellForItem(at: indexPath) as? MyCell else { return nil }\n        return cell.imageView\n    }\n    \n    // 6. (可选) 控制缩略图显隐，避免 Zoom 转场时视觉重叠\n    func photoBrowser(_ browser: JXPhotoBrowserViewController, setThumbnailHidden hidden: Bool, at index: Int) {\n        let indexPath = IndexPath(item: index, section: 0)\n        if let cell = collectionView.cellForItem(at: indexPath) as? MyCell {\n            cell.imageView.isHidden = hidden\n        }\n    }\n    \n    // 7. (可选) 自定义 Cell 尺寸，默认使用浏览器全屏尺寸\n    func photoBrowser(_ browser: JXPhotoBrowserViewController, sizeForItemAt index: Int) -\u003e CGSize? {\n        return nil // 返回 nil 使用默认尺寸\n    }\n}\n```\n\n## 在 SwiftUI 中使用\n\nJXPhotoBrowser 是基于 UIKit 的框架，在 SwiftUI 项目中可通过桥接方式集成。Demo-SwiftUI 示例工程演示了完整的集成方案。\n\n### 核心思路\n\n1. **网格和设置面板**使用纯 SwiftUI 实现（`LazyVGrid`、`Picker`、`AsyncImage` 等）\n2. **全屏图片浏览器**通过桥接层调用 `JXPhotoBrowserViewController`\n3. 创建一个 Presenter 类实现 `JXPhotoBrowserDelegate`，获取当前 `UIViewController` 后调用 `browser.present(from:)`\n\n### 桥接层示例\n\n```swift\nimport JXPhotoBrowser\n\n/// 封装 JXPhotoBrowserViewController 的创建、配置和呈现\nfinal class PhotoBrowserPresenter: JXPhotoBrowserDelegate {\n    private let items: [MyMediaItem]\n\n    func present(initialIndex: Int) {\n        guard let viewController = topViewController() else { return }\n\n        let browser = JXPhotoBrowserViewController()\n        browser.delegate = self\n        browser.initialIndex = initialIndex\n        browser.transitionType = .fade\n        browser.addOverlay(JXPageIndicatorOverlay())\n        browser.present(from: viewController)\n    }\n\n    func numberOfItems(in browser: JXPhotoBrowserViewController) -\u003e Int {\n        items.count\n    }\n\n    func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -\u003e JXPhotoBrowserAnyCell {\n        browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell\n    }\n\n    func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {\n        guard let photoCell = cell as? JXZoomImageCell else { return }\n        // 加载图片到 photoCell.imageView ...\n    }\n}\n```\n\n### 在 SwiftUI View 中调用\n\n```swift\nstruct ContentView: View {\n    // 持有 presenter（JXPhotoBrowserViewController.delegate 为 weak，需要外部强引用）\n    @State private var presenter: PhotoBrowserPresenter?\n\n    var body: some View {\n        LazyVGrid(columns: columns) {\n            ForEach(Array(items.enumerated()), id: \\.element.id) { index, item in\n                AsyncImage(url: item.thumbnailURL)\n                    .onTapGesture {\n                        let p = PhotoBrowserPresenter(items: items)\n                        presenter = p\n                        p.present(initialIndex: index)\n                    }\n            }\n        }\n    }\n}\n```\n\n\u003e **注意**：`JXPhotoBrowserViewController` 的 `delegate` 是 `weak` 引用，必须在 SwiftUI 侧用 `@State` 持有 Presenter 实例，否则它会在创建后立即被释放。\n\n### 关于 Zoom 转场\n\nDemo-SwiftUI 示例工程未演示 Zoom 转场动画，默认使用 Fade 转场。\n\n**原因**：Zoom 转场依赖 `thumbnailViewAt` delegate 方法返回列表中缩略图的 `UIView` 引用，框架通过该引用计算动画起止位置并构建临时动画视图。而 SwiftUI 的 `AsyncImage` 等原生视图无法直接提供底层 `UIView` 引用。\n\n**如需自行实现**：可将缩略图从 `AsyncImage` 替换为 `UIViewRepresentable` 包裹的 `UIImageView`，从而获取真实的 `UIView` 引用，再通过 `thumbnailViewAt` 和 `setThumbnailHidden` 两个 delegate 方法提供给框架即可。具体的 Zoom 转场接入方式可参考 Demo-UIKit 示例工程。\n\n## JXImageCell 加载指示器\n\n`JXImageCell` 内置了一个 `UIActivityIndicatorView` 加载指示器，**默认不启用**。适用于 Banner 等嵌入式场景下展示图片加载状态。\n\n### 启用加载指示器\n\n```swift\nlet cell = browser.dequeueReusableCell(withReuseIdentifier: JXImageCell.reuseIdentifier, for: indexPath) as! JXImageCell\n\n// 启用加载指示器\ncell.isLoadingIndicatorEnabled = true\ncell.startLoading()\n\n// 图片加载完成后停止\ncell.imageView.kf.setImage(with: imageURL) { [weak cell] _ in\n    cell?.stopLoading()\n}\n```\n\n### 自定义样式\n\n通过 `loadingIndicator` 属性可直接定制指示器的外观：\n\n```swift\ncell.loadingIndicator.style = .large       // 指示器尺寸\ncell.loadingIndicator.color = .systemBlue  // 指示器颜色\n```\n\n## 自定义 Cell\n\n框架支持两种方式创建自定义 Cell：\n\n### 方式一：继承 JXZoomImageCell（推荐）\n\n继承 `JXZoomImageCell` 可自动获得缩放、转场、手势等功能。以 Demo 中的 `VideoPlayerCell` 为例，它继承 `JXZoomImageCell` 并添加了视频播放能力：\n\n```swift\nclass VideoPlayerCell: JXZoomImageCell {\n    static let videoReuseIdentifier = \"VideoPlayerCell\"\n    \n    private var player: AVPlayer?\n    private var playerLayer: AVPlayerLayer?\n    \n    override init(frame: CGRect) {\n        super.init(frame: frame)\n        // 自定义初始化：添加 loading 指示器等\n    }\n    \n    /// 配置视频资源\n    func configure(videoURL: URL, coverImage: UIImage? = nil) {\n        imageView.image = coverImage\n        // 创建播放器并开始播放...\n    }\n    \n    /// 重写单击手势：暂停视频或关闭浏览器\n    override func handleSingleTap(_ gesture: UITapGestureRecognizer) {\n        if isPlaying {\n            pauseVideo()\n        } else {\n            browser?.dismissSelf()\n        }\n    }\n}\n```\n\n### 方式二：实现协议（完全自定义）\n\n直接实现 `JXPhotoBrowserCellProtocol` 协议，获得完全的自由度：\n\n```swift\nclass StandaloneCell: UICollectionViewCell, JXPhotoBrowserCellProtocol {\n    static let reuseIdentifier = \"StandaloneCell\"\n    \n    // 必须实现：弱引用浏览器（避免循环引用）\n    weak var browser: JXPhotoBrowserViewController?\n    \n    // 可选实现：用于 Zoom 转场动画，返回 nil 则使用 Fade 动画\n    var transitionImageView: UIImageView? { imageView }\n    \n    let imageView = UIImageView()\n    \n    override init(frame: CGRect) {\n        super.init(frame: frame)\n        // 自定义初始化\n    }\n    \n    // 可选实现：下拉关闭交互状态变化时调用\n    // isInteracting 为 true 表示用户正在下拉（图片缩小跟随手指），false 表示交互结束（回弹恢复）\n    // 适用于在拖拽关闭过程中暂停视频、隐藏附加 UI 等场景\n    func photoBrowserDismissInteractionDidChange(isInteracting: Bool) {\n        // 例如：下拉时暂停视频播放\n    }\n}\n```\n\n### 注册和使用自定义 Cell\n\n```swift\nlet browser = JXPhotoBrowserViewController()\n\n// 注册自定义 Cell（必须在设置 delegate 之前）\nbrowser.register(VideoPlayerCell.self, forReuseIdentifier: VideoPlayerCell.videoReuseIdentifier)\n\nbrowser.delegate = self\nbrowser.present(from: self)\n\n// 在 delegate 中使用\nfunc photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -\u003e JXPhotoBrowserAnyCell {\n    let cell = browser.dequeueReusableCell(withReuseIdentifier: VideoPlayerCell.videoReuseIdentifier, for: indexPath) as! VideoPlayerCell\n    cell.configure(videoURL: url, coverImage: thumbnail)\n    return cell\n}\n```\n\n## Overlay 组件\n\n框架提供了通用的 Overlay 组件机制，用于在浏览器上层叠加附加 UI（如页码指示器、关闭按钮、标题栏等）。**默认不装载任何 Overlay，业务方按需装载**。\n\n### 使用内置页码指示器\n\n框架内置了 `JXPageIndicatorOverlay`（基于 `UIPageControl`），一行代码即可装载：\n\n```swift\nlet browser = JXPhotoBrowserViewController()\nbrowser.addOverlay(JXPageIndicatorOverlay())\n```\n\n支持自定义位置和样式：\n\n```swift\nlet indicator = JXPageIndicatorOverlay()\nindicator.position = .bottom(padding: 20)  // 位置：底部距离 20pt（也支持 .top）\nindicator.hidesForSinglePage = true         // 仅一页时自动隐藏\nindicator.pageControl.currentPageIndicatorTintColor = .white\nindicator.pageControl.pageIndicatorTintColor = .lightGray\nbrowser.addOverlay(indicator)\n```\n\n### 自定义 Overlay\n\n实现 `JXPhotoBrowserOverlay` 协议即可创建自定义组件：\n\n```swift\nclass CloseButtonOverlay: UIView, JXPhotoBrowserOverlay {\n    \n    func setup(with browser: JXPhotoBrowserViewController) {\n        // 在此完成布局（如添加约束）\n    }\n    \n    func reloadData(numberOfItems: Int, pageIndex: Int) {\n        // 数据或布局变化时更新\n    }\n    \n    func didChangedPageIndex(_ index: Int) {\n        // 页码变化时更新\n    }\n}\n\n// 装载\nbrowser.addOverlay(CloseButtonOverlay())\n```\n\n多个 Overlay 可同时装载，互不干扰：\n\n```swift\nbrowser.addOverlay(JXPageIndicatorOverlay())\nbrowser.addOverlay(CloseButtonOverlay())\n```\n\n## 保存图片/视频到相册\n\n框架本身不内置保存功能，业务方可自行实现。Demo 中演示了通过长按手势弹出 ActionSheet 保存媒体到系统相册的完整流程。\n\n\u003e **前提**：需要在 `Info.plist` 中配置 `NSPhotoLibraryAddUsageDescription`（写入相册权限描述）。\n\n### 核心步骤\n\n1. **添加长按手势**：在自定义 Cell 中添加 `UILongPressGestureRecognizer`。\n2. **弹出 ActionSheet**：通过 `browser` 属性获取浏览器控制器来 present。\n3. **请求权限并保存**：使用 `PHPhotoLibrary` 请求权限，下载后写入相册。\n\n### 示例：在自定义 Cell 中长按保存\n\n以 Demo 中的 `VideoPlayerCell` 为例，继承 `JXZoomImageCell` 后添加长按保存能力：\n\n```swift\nimport Photos\n\nclass VideoPlayerCell: JXZoomImageCell {\n    \n    override init(frame: CGRect) {\n        super.init(frame: frame)\n        // 添加长按手势\n        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))\n        scrollView.addGestureRecognizer(longPress)\n    }\n    \n    @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {\n        guard gesture.state == .began else { return }\n        \n        let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)\n        alert.addAction(UIAlertAction(title: \"保存视频\", style: .default) { [weak self] _ in\n            self?.saveVideoToAlbum()\n        })\n        alert.addAction(UIAlertAction(title: \"取消\", style: .cancel))\n        \n        // 通过 browser 属性获取浏览器控制器来 present\n        browser?.present(alert, animated: true)\n    }\n    \n    private func saveVideoToAlbum() {\n        guard let url = videoURL else { return }\n        \n        // 1. 请求相册写入权限\n        PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in\n            guard status == .authorized || status == .limited else { return }\n            \n            // 2. 下载视频（远程 URL 需先下载到本地）\n            URLSession.shared.downloadTask(with: url) { tempURL, _, _ in\n                guard let tempURL else { return }\n                \n                // 3. 写入相册\n                PHPhotoLibrary.shared().performChanges({\n                    PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tempURL)\n                }) { success, error in\n                    // 处理结果...\n                }\n            }.resume()\n        }\n    }\n}\n```\n\n保存图片的流程类似，将下载部分替换为图片写入即可：\n\n```swift\n// 下载图片数据\nURLSession.shared.dataTask(with: imageURL) { data, _, _ in\n    guard let data, let image = UIImage(data: data) else { return }\n    \n    PHPhotoLibrary.shared().performChanges({\n        PHAssetChangeRequest.creationRequestForAsset(from: image)\n    }) { success, error in\n        // 处理结果...\n    }\n}.resume()\n```\n\n## 常见问题 (FAQ)\n\n### Q: Zoom 转场动画时图片尺寸不对或有闪烁现象？\n\n**A**: 这通常是因为打开浏览器时，目标 Cell 的 `imageView` 还没有设置图片，导致其 `bounds` 为 zero。\n\n**解决方案**：在 `willDisplay` 代理方法中，确保同步设置占位图。例如使用 Kingfisher 时：\n\n```swift\nfunc photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {\n    guard let photoCell = cell as? JXZoomImageCell else { return }\n    \n    // 同步从缓存取出缩略图作为占位图\n    let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: thumbnailURL.absoluteString)\n    photoCell.imageView.kf.setImage(with: imageURL, placeholder: placeholder) { [weak photoCell] _ in\n        photoCell?.setNeedsLayout()\n    }\n}\n```\n\n这样可以确保转场动画开始时，Cell 已经有正确尺寸的图片，动画效果更加流畅。\n\n## 版本更新\n\n**v4.0.2**（2026/02/16）— 优化双击放大体验：以点击位置为锚点放大，优化缩小动画流畅度。\n\n**v4.0.1**（2026/02/10）— 全面重构，回归 `UICollectionView`，支持无限循环滚动，新增 SwiftUI 示例。不兼容 3.x 版本。\n\n完整更新记录请查看 [CHANGELOG](CHANGELOG.md)。\n\n## License\n\n本项目基于 MIT 协议开源。\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjiongxing%2Fphotobrowser","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjiongxing%2Fphotobrowser","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjiongxing%2Fphotobrowser/lists"}