├── .gitattributes
├── .github
├── dawn.png
└── example.gif
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── LICENSE
├── Package.swift
├── Playground.swiftpm
├── .swiftpm
│ └── xcode
│ │ └── package.xcworkspace
│ │ └── contents.xcworkspacedata
├── App.swift
├── ContentView.swift
├── Package.swift
├── Resources
│ └── Media.xcassets
│ │ ├── Contents.json
│ │ ├── image1.imageset
│ │ ├── Contents.json
│ │ └── image.png
│ │ ├── image2.imageset
│ │ ├── Contents.json
│ │ └── image2.png
│ │ ├── image3.imageset
│ │ ├── Contents.json
│ │ └── image3.png
│ │ └── image4.imageset
│ │ ├── Contents.json
│ │ └── image4.png
├── TextItem.swift
└── ViewController.swift
├── README.md
├── Sources
├── MediaViewer
│ ├── Extensions
│ │ ├── UIKit+.swift
│ │ └── WorkaroundNavigationController.swift
│ ├── PreviewItem
│ │ └── PreviewItem.swift
│ ├── PreviewItemView
│ │ └── PreviewItemViewController.swift
│ ├── Resources
│ │ └── PrivacyInfo.xcprivacy
│ ├── Transition
│ │ ├── Animator
│ │ │ ├── Animator.swift
│ │ │ ├── DismissAnimator.swift
│ │ │ ├── PresentAnimator.swift
│ │ │ └── ReversedDismissAnimator.swift
│ │ ├── InteractiveTransition.swift
│ │ ├── PresentationController.swift
│ │ └── Presenter.swift
│ └── UI
│ │ ├── NavigationController.swift
│ │ ├── PageViewController.swift
│ │ ├── PreviewController.swift
│ │ ├── PreviewControllerDataSource.swift
│ │ └── PreviewControllerDelegate.swift
└── MediaViewerBuiltins
│ ├── Extensions
│ └── AVKit+.swift
│ ├── PreviewItem
│ ├── AVPlayer+.swift
│ └── UIImage+.swift
│ └── PreviewItemView
│ ├── ImagePreviewItemViewController.swift
│ ├── PlayerPreviewItemViewController.swift
│ ├── ThumbnailViewController.swift
│ └── ToolbarItem
│ └── Seekbar.swift
└── Tests
└── MediaViewerTests
└── MediaViewerTests.swift
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.github/dawn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noppefoxwolf/MediaViewer/8d43c1cc2e66ee32facb7bd3ae9d1f1c4102ad8e/.github/dawn.png
--------------------------------------------------------------------------------
/.github/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noppefoxwolf/MediaViewer/8d43c1cc2e66ee32facb7bd3ae9d1f1c4102ad8e/.github/example.gif
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
92 | .DS_Store
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 noppefoxwolf
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "MediaViewer",
8 | platforms: [.iOS(.v16)],
9 | products: [
10 | .library(
11 | name: "MediaViewer",
12 | targets: ["MediaViewer"]
13 | ),
14 | .library(
15 | name: "MediaViewerBuiltins",
16 | targets: ["MediaViewerBuiltins"]
17 | )
18 | ],
19 | dependencies: [
20 | /* No dependencies!! */
21 | ],
22 | targets: [
23 | .target(
24 | name: "MediaViewer"
25 | ),
26 | .target(
27 | name: "MediaViewerBuiltins",
28 | dependencies: [
29 | "MediaViewer"
30 | ]
31 | ),
32 | .testTarget(
33 | name: "MediaViewerTests",
34 | dependencies: ["MediaViewer"]
35 | ),
36 | ]
37 | )
38 |
--------------------------------------------------------------------------------
/Playground.swiftpm/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Playground.swiftpm/App.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct App: SwiftUI.App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Playground.swiftpm/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ContentView: UIViewControllerRepresentable {
4 | func makeUIViewController(context: Context) -> some UIViewController {
5 | ViewController()
6 | }
7 |
8 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
9 | }
10 | }
11 |
12 |
--------------------------------------------------------------------------------
/Playground.swiftpm/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.10
2 |
3 | // WARNING:
4 | // This file is automatically generated.
5 | // Do not edit it by hand because the contents will be replaced.
6 |
7 | import PackageDescription
8 | import AppleProductTypes
9 |
10 | let package = Package(
11 | name: "Playground",
12 | platforms: [
13 | .iOS("16.0")
14 | ],
15 | products: [
16 | .iOSApplication(
17 | name: "Playground",
18 | targets: ["AppModule"],
19 | bundleIdentifier: "10D4861E-A98F-4ACA-A4F3-8CF9D7C74536",
20 | teamIdentifier: "FBQ6Z8AF3U",
21 | displayVersion: "1.0",
22 | bundleVersion: "1",
23 | supportedDeviceFamilies: [
24 | .pad,
25 | .phone
26 | ],
27 | supportedInterfaceOrientations: [
28 | .portrait,
29 | .landscapeRight,
30 | .landscapeLeft,
31 | .portraitUpsideDown(.when(deviceFamilies: [.pad]))
32 | ],
33 | capabilities: [
34 | .photoLibrary(purposeString: "Save image and video")
35 | ]
36 | )
37 | ],
38 | dependencies: [
39 | .package(path: "..")
40 | ],
41 | targets: [
42 | .executableTarget(
43 | name: "AppModule",
44 | dependencies: [
45 | .product(name: "MediaViewer", package: "mediaviewer"),
46 | .product(name: "MediaViewerBuiltins", package: "mediaviewer")
47 | ],
48 | path: "."
49 | )
50 | ]
51 | )
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/image1.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/image1.imageset/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noppefoxwolf/MediaViewer/8d43c1cc2e66ee32facb7bd3ae9d1f1c4102ad8e/Playground.swiftpm/Resources/Media.xcassets/image1.imageset/image.png
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/image2.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image2.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/image2.imageset/image2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noppefoxwolf/MediaViewer/8d43c1cc2e66ee32facb7bd3ae9d1f1c4102ad8e/Playground.swiftpm/Resources/Media.xcassets/image2.imageset/image2.png
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/image3.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image3.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/image3.imageset/image3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noppefoxwolf/MediaViewer/8d43c1cc2e66ee32facb7bd3ae9d1f1c4102ad8e/Playground.swiftpm/Resources/Media.xcassets/image3.imageset/image3.png
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/image4.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image4.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Playground.swiftpm/Resources/Media.xcassets/image4.imageset/image4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noppefoxwolf/MediaViewer/8d43c1cc2e66ee32facb7bd3ae9d1f1c4102ad8e/Playground.swiftpm/Resources/Media.xcassets/image4.imageset/image4.png
--------------------------------------------------------------------------------
/Playground.swiftpm/TextItem.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import MediaViewer
3 | import SwiftUI
4 |
5 | extension TextItem: PreviewItem {
6 | public func makeViewController() async -> UIViewController {
7 | try! await Task.sleep(for: .seconds(3))
8 | let vc = UIHostingController(rootView: Group {
9 | Text(text)
10 | .foregroundColor(.black)
11 | .background(.white)
12 | })
13 | vc.view.backgroundColor = .clear
14 | return vc
15 | }
16 |
17 | public func makeThumbnailViewController() -> UIViewController? {
18 | let vc = UIHostingController(rootView: Group {
19 | Text("Loading...")
20 | .foregroundColor(.gray)
21 | .background(.white)
22 | })
23 | vc.view.backgroundColor = .clear
24 | return vc
25 | }
26 |
27 | public func makeActivityItemsConfiguration() -> UIActivityItemsConfigurationReading? {
28 | nil
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Playground.swiftpm/ViewController.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import AVFoundation
3 | import UIKit
4 | import MediaViewer
5 | import MediaViewerBuiltins
6 |
7 | enum Section: Int {
8 | case items
9 | }
10 |
11 | enum Item: Hashable {
12 | case image(UIImage)
13 | case player(AVPlayer)
14 | case text(TextItem)
15 | }
16 |
17 | struct TextItem: Sendable, Equatable, Hashable {
18 | let text: String
19 | }
20 |
21 | extension Item {
22 | func makePreviewItem() -> any PreviewItem {
23 | switch self {
24 | case .image(let image):
25 | return image
26 | case .player(let player):
27 | return player
28 | case .text(let textItem):
29 | return textItem
30 | }
31 | }
32 | }
33 |
34 | final class ViewController: UICollectionViewController {
35 | let cellRegistration = UICollectionView.CellRegistration(
36 | handler: { (cell: UICollectionViewCell, indexPath, item: Item) in
37 | cell.contentConfiguration = UIHostingConfiguration(content: {
38 | switch item {
39 | case .image(let image):
40 | Color.clear
41 | .overlay {
42 | Image(uiImage: image)
43 | .resizable()
44 | .scaledToFill()
45 | }
46 | .clipped()
47 | case .player(let aVPlayer):
48 | Color.black
49 | case .text(let textItem):
50 | Text(textItem.text)
51 | }
52 | })
53 | }
54 | )
55 |
56 | lazy var dataSource = UICollectionViewDiffableDataSource(
57 | collectionView: collectionView,
58 | cellProvider: { [unowned self] (collectionView, indexPath, item) in
59 | collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
60 | }
61 | )
62 |
63 | var snapshot = NSDiffableDataSourceSnapshot()
64 |
65 | init() {
66 | let layout = UICollectionViewFlowLayout()
67 | layout.minimumLineSpacing = 0
68 | layout.minimumInteritemSpacing = 0
69 | layout.itemSize = CGSize(width: 200, height: 200)
70 | super.init(collectionViewLayout: layout)
71 | }
72 |
73 | required init?(coder: NSCoder) {
74 | fatalError("init(coder:) has not been implemented")
75 | }
76 |
77 | override func viewDidLoad() {
78 | super.viewDidLoad()
79 | collectionView.dataSource = dataSource
80 |
81 | snapshot.appendSections([.items])
82 | snapshot.appendItems([
83 | Item.image(UIImage(named: "image1")!),
84 | Item.image(UIImage(named: "image2")!),
85 | Item.image(UIImage(named: "image3")!),
86 | Item.image(UIImage(named: "image4")!),
87 | Item.player({
88 | let url = URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8")!
89 | let player = AVPlayer(url: url)
90 | player.automaticallyWaitsToMinimizeStalling = true
91 | return player
92 | }()),
93 | Item.text(TextItem(text: "Hello, World!"))
94 | ], toSection: .items)
95 |
96 | dataSource.apply(snapshot)
97 | }
98 |
99 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
100 | let vc = PreviewController()
101 | vc.delegate = self
102 | vc.dataSource = self
103 | vc.currentPreviewItemIndex = indexPath.row
104 | present(vc, animated: true)
105 | }
106 | }
107 |
108 | extension ViewController: PreviewControllerDataSource {
109 | func numberOfPreviewItems(in controller: PreviewController) -> Int {
110 | dataSource.collectionView(collectionView, numberOfItemsInSection: 0)
111 | }
112 |
113 | func previewController(_ controller: PreviewController, previewItemAt index: Int) -> any PreviewItem {
114 | let item = dataSource.itemIdentifier(for: IndexPath(row: index, section: 0))!
115 | return item.makePreviewItem()
116 | }
117 | }
118 |
119 | extension ViewController: PreviewControllerDelegate {
120 | func previewController(
121 | _ controller: PreviewController,
122 | transitionViewFor item: any PreviewItem
123 | ) -> UIView? {
124 | let indexPath = collectionView.indexPathsForSelectedItems!.first!
125 | return collectionView.cellForItem(at: indexPath)
126 | }
127 | }
128 |
129 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MediaViewer
2 |
3 | 
4 |
5 | ## Features
6 |
7 | - [x] No dependencies
8 | - [x] Playback video
9 | - [x] Morphing transition
10 | - [x] Waiting for preview with thumbnail
11 | - [x] Custom preview
12 | - [x] Lazy load asset
13 | - [x] Landscape
14 |
15 | ## Installation
16 |
17 | ```swift
18 | dependencies: [
19 | .package(url: "https://github.com/noppefoxwolf/MediaViewer", from: "x.x.x")
20 | ],
21 | ```
22 |
23 | ## Usage
24 |
25 | ### PreviewItem
26 |
27 | The `PreviewItem` is a protocol that provides the behavior necessary for previewing.
28 | `UIImage` and `AVPlayer` have built-in implementations.
29 | It can also be customized.
30 |
31 | ```swift
32 | extension String: PreviewItem {
33 | func makeViewController() async -> UIViewController {
34 | UIHostingController(rootView: Text(self))
35 | }
36 |
37 | func makeThumbnailViewController() -> UIViewController? {
38 | nil
39 | }
40 | }
41 | ```
42 |
43 | ### PreviewController
44 |
45 | `PreviewItem` are displayed using `PreviewController`.
46 | `PreviewController` uses a `PreviewControllerDataSource` to retrieve `PreviewItem`.
47 |
48 | ```swift
49 | let vc = PreviewController()
50 | vc.delegate = self
51 | vc.dataSource = self
52 | present(vc, animated: true)
53 | ```
54 |
55 | ### PreviewControllerDataSource
56 |
57 | ```swift
58 | func numberOfPreviewItems(in controller: PreviewController) -> Int
59 | func previewController(_ controller: PreviewController, previewItemAt index: Int) -> any PreviewItem
60 | ```
61 |
62 | ### PreviewControllerDelegate
63 |
64 | `PreviewControllerDelegate` supports animations on transitions.
65 |
66 | ```swift
67 | func previewController(_ controller: PreviewController, transitionViewFor item: any PreviewItem) -> UIView?
68 | ```
69 |
70 | ## Apps Using
71 |
72 |
73 |
74 |
75 |
76 | If you use a MediaViewer, add your app via Pull Request.
77 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Extensions/UIKit+.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | extension UIViewController {
4 | func embed(_ viewController: UIViewController) {
5 | viewController.willMove(toParent: self)
6 | view.addSubview(viewController.view)
7 | viewController.view.translatesAutoresizingMaskIntoConstraints = false
8 | addChild(viewController)
9 | viewController.didMove(toParent: self)
10 | NSLayoutConstraint.activate([
11 | viewController.view.topAnchor.constraint(equalTo: view.topAnchor),
12 | view.bottomAnchor.constraint(equalTo: viewController.view.bottomAnchor),
13 | viewController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
14 | view.rightAnchor.constraint(equalTo: viewController.view.rightAnchor),
15 | ])
16 | }
17 |
18 | func digup() {
19 | willMove(toParent: nil)
20 | view.removeFromSuperview()
21 | removeFromParent()
22 | didMove(toParent: nil)
23 | }
24 | }
25 |
26 | extension UIImage {
27 | static func makeBarBackground() -> UIImage {
28 | let size = CGSize(width: 32, height: 32)
29 | let renderer = UIGraphicsImageRenderer(size: size)
30 | return renderer.image { context in
31 | let pathRect = CGRect(origin: .zero, size: size)
32 | let path = CGPath(ellipseIn: pathRect, transform: nil)
33 | context.cgContext.setFillColor(CGColor(gray: 0, alpha: 0.5))
34 | context.cgContext.addPath(path)
35 | context.cgContext.fillPath()
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Extensions/WorkaroundNavigationController.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 |
3 | open class WorkaroundNavigationController: UINavigationController {
4 |
5 | // workaround: always use hidesBarsOnTap
6 | private let tapGesture = UITapGestureRecognizer()
7 | open override var hidesBarsOnTap: Bool {
8 | get {
9 | tapGesture.view != nil
10 | }
11 | set {
12 | view.removeGestureRecognizer(tapGesture)
13 | tapGesture.removeTarget(self, action: #selector(onTap))
14 | tapGesture.addTarget(self, action: #selector(onTap))
15 | view.addGestureRecognizer(tapGesture)
16 | }
17 | }
18 |
19 | @objc private func onTap(_ gesture: UITapGestureRecognizer) {
20 | setToolbarHidden(!isToolbarHidden, animated: true)
21 | setNavigationBarHidden(!isNavigationBarHidden, animated: true)
22 | }
23 |
24 | // workaround: No re-layout when navigation bar is hidden
25 | private var _isNavigationBarHidden: Bool = false
26 | public override var isNavigationBarHidden: Bool {
27 | get { _isNavigationBarHidden }
28 | set { _isNavigationBarHidden = newValue }
29 | }
30 |
31 | private var _isToolbarHidden: Bool = false
32 | public override var isToolbarHidden: Bool {
33 | get { _isToolbarHidden }
34 | set { _isToolbarHidden = newValue }
35 | }
36 |
37 | public override func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
38 | guard isNavigationBarHidden != hidden else { return }
39 | let toTransform: CGAffineTransform
40 | if hidden {
41 | toTransform = CGAffineTransform(
42 | translationX: 0,
43 | y: -(view.safeAreaInsets.top + navigationBar.bounds.height)
44 | )
45 | } else {
46 | toTransform = .identity
47 | }
48 | let animator = UIViewPropertyAnimator(
49 | duration: UINavigationController.hideShowBarDuration,
50 | curve: .easeInOut
51 | )
52 | animator.addAnimations { [weak self] in
53 | self?.navigationBar.transform = toTransform
54 | }
55 | animator.addCompletion { [weak self] _ in
56 | self?.isNavigationBarHidden = hidden
57 | }
58 | animator.startAnimation()
59 | }
60 |
61 | public override func setToolbarHidden(_ hidden: Bool, animated: Bool) {
62 | guard isToolbarHidden != hidden else { return }
63 | let toTransform: CGAffineTransform
64 | if hidden {
65 | toTransform = CGAffineTransform(
66 | translationX: 0,
67 | y: view.safeAreaInsets.bottom + toolbar.bounds.height
68 | )
69 | } else {
70 | toTransform = .identity
71 | }
72 | let animator = UIViewPropertyAnimator(
73 | duration: UINavigationController.hideShowBarDuration,
74 | curve: .easeInOut
75 | )
76 | animator.addAnimations { [weak self] in
77 | self?.toolbar.transform = toTransform
78 | }
79 | animator.addCompletion { [weak self] _ in
80 | self?.isToolbarHidden = hidden
81 | }
82 | animator.startAnimation()
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/PreviewItem/PreviewItem.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 |
3 | public protocol PreviewItem {
4 | @MainActor
5 | func makeViewController() async -> UIViewController
6 |
7 | @MainActor
8 | func makeThumbnailViewController() -> UIViewController?
9 |
10 | @MainActor
11 | func makeActivityItemsConfiguration() -> (any UIActivityItemsConfigurationReading)?
12 | }
13 |
14 | extension PreviewItem {
15 | public func makeActivityItemsConfiguration() -> (any UIActivityItemsConfigurationReading)? {
16 | nil
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/PreviewItemView/PreviewItemViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | final class PreviewItemViewController: UIViewController {
4 | let previewItem: any PreviewItem
5 | let index: Int
6 | var readyToPreviewTask: Task? = nil
7 |
8 | init(_ previewItem: any PreviewItem, index: Int) {
9 | self.previewItem = previewItem
10 | self.index = index
11 | super.init(nibName: nil, bundle: nil)
12 | }
13 |
14 | required init?(coder: NSCoder) { fatalError() }
15 |
16 | override func viewDidLoad() {
17 | super.viewDidLoad()
18 | view.isUserInteractionEnabled = true
19 | view.backgroundColor = .clear
20 |
21 | let thumbnailVC = previewItem.makeThumbnailViewController()
22 | if let thumbnailVC {
23 | embed(thumbnailVC)
24 | }
25 |
26 | readyToPreviewTask = Task {
27 | let contentVC = await previewItem.makeViewController()
28 | thumbnailVC?.digup()
29 | embed(contentVC)
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Resources/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyAccessedAPITypes
6 |
7 | NSPrivacyTracking
8 |
9 | NSPrivacyCollectedDataTypes
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Transition/Animator/Animator.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 |
3 | open class Animator: NSObject, UIViewControllerAnimatedTransitioning {
4 | open func transitionDuration(
5 | using transitionContext: (any UIViewControllerContextTransitioning)?
6 | ) -> TimeInterval {
7 | CATransaction.animationDuration()
8 | }
9 |
10 | open func animateTransition(
11 | using transitionContext: any UIViewControllerContextTransitioning
12 | ) {
13 | interruptibleAnimator(using: transitionContext).startAnimation()
14 | }
15 |
16 | open func interruptibleAnimator(using transitionContext: any UIViewControllerContextTransitioning) -> any UIViewImplicitlyAnimating {
17 | UIViewPropertyAnimator(
18 | duration: transitionDuration(using: transitionContext),
19 | curve: .linear
20 | )
21 | }
22 | }
23 |
24 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Transition/Animator/DismissAnimator.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | final class DismissAnimator: Animator {
4 | override func interruptibleAnimator(
5 | using transitionContext: any UIViewControllerContextTransitioning
6 | ) -> any UIViewImplicitlyAnimating {
7 | let previewController = transitionContext.viewController(forKey: .from) as! PreviewController
8 | let animator = UIViewPropertyAnimator(
9 | duration: transitionDuration(using: transitionContext),
10 | curve: .linear
11 | )
12 | animator.addAnimations {
13 | transitionContext.containerView.backgroundColor = .clear
14 | previewController
15 | .internalNavigationController
16 | .topViewController?
17 | .view
18 | .transform = CGAffineTransform(
19 | translationX: 0,
20 | y: transitionContext.containerView.bounds.height
21 | )
22 | }
23 | animator.addCompletion { _ in
24 | let didComplete = !transitionContext.transitionWasCancelled
25 | transitionContext.completeTransition(didComplete)
26 | }
27 | return animator
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Transition/Animator/PresentAnimator.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | final class PresentAnimator: Animator {
4 | override func interruptibleAnimator(
5 | using transitionContext: any UIViewControllerContextTransitioning
6 | ) -> any UIViewImplicitlyAnimating {
7 | let previewController = transitionContext.viewController(forKey: .to) as! PreviewController
8 |
9 | let duration = transitionDuration(using: transitionContext)
10 | let animator = UIViewPropertyAnimator(
11 | duration: duration,
12 | dampingRatio: 0.82
13 | )
14 | animator.addAnimations {
15 | previewController
16 | .currentTransitionView?
17 | .alpha = 0
18 | }
19 | animator.addAnimations {
20 | transitionContext.containerView.backgroundColor = .black
21 | }
22 | animator.addAnimations {
23 | previewController
24 | .topView?
25 | .alpha = 1
26 | }
27 | animator.addAnimations {
28 | previewController
29 | .topView?
30 | .transform = .identity
31 | }
32 | animator.addCompletion { _ in
33 | previewController
34 | .currentTransitionView?
35 | .alpha = 1
36 | let didComplete = !transitionContext.transitionWasCancelled
37 | transitionContext.completeTransition(didComplete)
38 | }
39 | return animator
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Transition/Animator/ReversedDismissAnimator.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | final class ReversedDismissAnimator: Animator {
4 | override func interruptibleAnimator(
5 | using transitionContext: any UIViewControllerContextTransitioning
6 | ) -> any UIViewImplicitlyAnimating {
7 | let previewController = transitionContext.viewController(forKey: .from) as! PreviewController
8 | let animator = UIViewPropertyAnimator(
9 | duration: transitionDuration(using: transitionContext),
10 | curve: .linear
11 | )
12 | animator.addAnimations {
13 | transitionContext.containerView.backgroundColor = .clear
14 | previewController
15 | .internalNavigationController
16 | .topViewController?
17 | .view
18 | .transform = CGAffineTransform(
19 | translationX: 0,
20 | y: -transitionContext.containerView.bounds.height
21 | )
22 | }
23 | animator.addCompletion { _ in
24 | let didComplete = !transitionContext.transitionWasCancelled
25 | transitionContext.completeTransition(didComplete)
26 | }
27 | return animator
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Transition/InteractiveTransition.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | final class InteractiveTransition: UIPercentDrivenInteractiveTransition {
4 | override init() {
5 | super.init()
6 | completionCurve = .linear
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Transition/PresentationController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | final class PresentationController: UIPresentationController {
4 | override func presentationTransitionWillBegin() {
5 | containerView?.backgroundColor = .clear
6 | containerView?.addSubview(presentedView!)
7 | let previewController = presentedViewController as! PreviewController
8 |
9 | previewController
10 | .topView?
11 | .alpha = 0
12 |
13 | let fromTransition: CGAffineTransform
14 | let fromScale: Double
15 | let transitionView = previewController.currentTransitionView
16 | if let containerView, let transitionView {
17 | let point = transitionView
18 | .convert(transitionView.bounds, to: containerView)
19 | .applying(.init(
20 | translationX: -containerView.center.x,
21 | y: -containerView.center.y
22 | ))
23 | fromTransition = CGAffineTransform(translationX: point.midX, y: point.midY)
24 | fromScale = transitionView.bounds.width / containerView.bounds.width
25 | } else {
26 | fromTransition = .identity
27 | fromScale = 0.85
28 | }
29 | previewController
30 | .topView?
31 | .transform = fromTransition.scaledBy(x: fromScale, y: fromScale)
32 | }
33 |
34 | override func dismissalTransitionDidEnd(_ completed: Bool) {
35 | if completed {
36 | presentedView?.removeFromSuperview()
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/Transition/Presenter.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | final class Presenter: NSObject, UIViewControllerTransitioningDelegate {
4 | var interactiveTransition: InteractiveTransition? = nil
5 | var reversedDismiss: Bool = false
6 |
7 | func presentationController(
8 | forPresented presented: UIViewController,
9 | presenting: UIViewController?,
10 | source: UIViewController
11 | ) -> UIPresentationController? {
12 | PresentationController(
13 | presentedViewController: presented,
14 | presenting: presenting
15 | )
16 | }
17 |
18 | func animationController(
19 | forPresented presented: UIViewController,
20 | presenting: UIViewController,
21 | source: UIViewController
22 | ) -> (any UIViewControllerAnimatedTransitioning)? {
23 | PresentAnimator()
24 | }
25 |
26 | func animationController(
27 | forDismissed dismissed: UIViewController
28 | ) -> (any UIViewControllerAnimatedTransitioning)? {
29 | reversedDismiss ? ReversedDismissAnimator() : DismissAnimator()
30 | }
31 |
32 | func interactionControllerForDismissal(
33 | using animator: any UIViewControllerAnimatedTransitioning
34 | ) -> (any UIViewControllerInteractiveTransitioning)? {
35 | interactiveTransition
36 | }
37 | }
38 |
39 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/UI/NavigationController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | final class NavigationController: WorkaroundNavigationController {
4 | override func viewDidLoad() {
5 | super.viewDidLoad()
6 | setBarHidden(false, animated: false)
7 |
8 | navigationBar.standardAppearance.configureWithTransparentBackground()
9 | navigationBar.tintColor = .white
10 |
11 | toolbar.standardAppearance = UIToolbarAppearance()
12 | toolbar.standardAppearance.configureWithTransparentBackground()
13 | toolbar.tintColor = .white
14 |
15 | hidesBarsOnTap = true
16 | }
17 |
18 | func setBarHidden(_ hidden: Bool, animated: Bool) {
19 | setNavigationBarHidden(hidden, animated: animated)
20 | setToolbarHidden(hidden, animated: animated)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/UI/PageViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | @MainActor
4 | protocol PageViewControllerUIDelegate: AnyObject {
5 | func dismissActionTriggered()
6 | func presentActivityActionTriggered()
7 | }
8 |
9 | final class PageViewController: UIPageViewController {
10 | weak var uiDelegate: (any PageViewControllerUIDelegate)? = nil
11 |
12 | override func viewDidLoad() {
13 | super.viewDidLoad()
14 | view.backgroundColor = .clear
15 |
16 | navigationItem.leftBarButtonItem = UIBarButtonItem(
17 | image: UIImage(systemName: "xmark"),
18 | primaryAction: UIAction { [weak self] _ in
19 | self?.uiDelegate?.dismissActionTriggered()
20 | }
21 | )
22 | navigationItem.leftBarButtonItem?.setBackgroundImage(.makeBarBackground(), for: .normal, barMetrics: .default)
23 |
24 | navigationItem.rightBarButtonItem = UIBarButtonItem(
25 | image: UIImage(systemName: "ellipsis"),
26 | primaryAction: UIAction { [weak self] _ in
27 | self?.uiDelegate?.presentActivityActionTriggered()
28 | }
29 | )
30 | navigationItem.rightBarButtonItem?.setBackgroundImage(.makeBarBackground(), for: .normal, barMetrics: .default)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/UI/PreviewController.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 | import os
3 |
4 | open class PreviewController: UIViewController {
5 | let logger = Logger(
6 | subsystem: Bundle.main.bundleIdentifier!,
7 | category: #file
8 | )
9 |
10 | private let presenter = Presenter()
11 |
12 | public weak var dataSource: (any PreviewControllerDataSource)? = nil
13 | public weak var delegate: (any PreviewControllerDelegate)? = nil
14 |
15 | let internalNavigationController = NavigationController(
16 | navigationBarClass: UINavigationBar.self,
17 | toolbarClass: Toolbar.self
18 | )
19 |
20 | var wasToolbarHidden: Bool = false
21 |
22 | let pageViewController = PageViewController(
23 | transitionStyle: .scroll,
24 | navigationOrientation: .horizontal,
25 | options: [.interPageSpacing : UIStackView.spacingUseSystem]
26 | )
27 |
28 | public var currentPreviewItemIndex: Int = 0
29 |
30 | public var currentPreviewItem: (any PreviewItem)? {
31 | dataSource?.previewController(self, previewItemAt: currentPreviewItemIndex)
32 | }
33 |
34 | public func refreshCurrentPreviewItem() {
35 |
36 | let item = dataSource?.previewController(
37 | self,
38 | previewItemAt: currentPreviewItemIndex
39 | )
40 | if let item {
41 | let vc = PreviewItemViewController(
42 | item,
43 | index: currentPreviewItemIndex
44 | )
45 | pageViewController.setViewControllers(
46 | [vc],
47 | direction: .forward,
48 | animated: false
49 | )
50 | }
51 | }
52 |
53 | public init() {
54 | super.init(nibName: nil, bundle: nil)
55 | transitioningDelegate = presenter
56 | modalPresentationStyle = .custom
57 | }
58 |
59 | public required init?(coder: NSCoder) { fatalError() }
60 |
61 | open override func viewDidLoad() {
62 | super.viewDidLoad()
63 | view.backgroundColor = .clear
64 | embed(internalNavigationController)
65 | internalNavigationController.setViewControllers([pageViewController], animated: false)
66 | refreshCurrentPreviewItem()
67 | pageViewController.delegate = self
68 | pageViewController.dataSource = self
69 | pageViewController.uiDelegate = self
70 |
71 | let panGesture = UIPanGestureRecognizer()
72 | panGesture.delegate = self
73 | panGesture.addTarget(self, action: #selector(onPan))
74 | view.addGestureRecognizer(panGesture)
75 |
76 | let longPressGesture = UILongPressGestureRecognizer()
77 | longPressGesture.addTarget(self, action: #selector(onLongPress))
78 | view.addGestureRecognizer(longPressGesture)
79 | }
80 |
81 | @objc private func onPan(_ gesture: UIPanGestureRecognizer) {
82 | let translation = gesture.translation(in: gesture.view)
83 | switch gesture.state {
84 | case .began:
85 | presenter.interactiveTransition = InteractiveTransition()
86 | wasToolbarHidden = internalNavigationController.isToolbarHidden
87 | internalNavigationController.setBarHidden(true, animated: true)
88 | dismiss(animated: true)
89 | case .changed:
90 | let percentComplete = translation.y / gesture.view!.bounds.height
91 | if percentComplete < 0 {
92 | if !presenter.reversedDismiss {
93 | presenter.interactiveTransition?.cancel()
94 | presenter.interactiveTransition = InteractiveTransition()
95 | presenter.reversedDismiss.toggle()
96 | dismiss(animated: true)
97 | }
98 | } else {
99 | if presenter.reversedDismiss {
100 | presenter.interactiveTransition?.cancel()
101 | presenter.interactiveTransition = InteractiveTransition()
102 | presenter.reversedDismiss.toggle()
103 | dismiss(animated: true)
104 | }
105 | }
106 | presenter.interactiveTransition?.update(abs(percentComplete))
107 | case .ended:
108 | if abs(translation.y) > 60 {
109 | presenter.interactiveTransition?.finish()
110 | presenter.interactiveTransition = nil
111 | } else {
112 | presenter.interactiveTransition?.cancel()
113 | presenter.interactiveTransition = nil
114 | internalNavigationController.setBarHidden(wasToolbarHidden, animated: true)
115 | }
116 | case .cancelled, .failed:
117 | presenter.interactiveTransition?.cancel()
118 | presenter.interactiveTransition = nil
119 | internalNavigationController.setBarHidden(wasToolbarHidden, animated: true)
120 | default:
121 | break
122 | }
123 | }
124 |
125 | @objc private func onLongPress(_ gesture: UILongPressGestureRecognizer) {
126 | guard gesture.state == .began else { return }
127 | presentActivityActionTriggered()
128 | }
129 | }
130 |
131 | extension PreviewController: UIGestureRecognizerDelegate {
132 | public func gestureRecognizerShouldBegin(
133 | _ gestureRecognizer: UIGestureRecognizer
134 | ) -> Bool {
135 | let panGesture = gestureRecognizer as? UIPanGestureRecognizer
136 | guard let panGesture else { return false }
137 | let velocity = panGesture.velocity(in: panGesture.view)
138 | return abs(velocity.y) > abs(velocity.x)
139 | }
140 | }
141 |
142 | extension PreviewController: UIPageViewControllerDataSource {
143 | public func pageViewController(
144 | _ pageViewController: UIPageViewController,
145 | viewControllerBefore viewController: UIViewController
146 | ) -> UIViewController? {
147 | let previewItemViewController = viewController as! PreviewItemViewController
148 | let beforeIndex = previewItemViewController.index - 1
149 | guard beforeIndex >= 0 else { return nil }
150 | let item = dataSource?.previewController(
151 | self,
152 | previewItemAt: beforeIndex
153 | )
154 | guard let item else { return nil }
155 | return PreviewItemViewController(item, index: beforeIndex)
156 | }
157 |
158 | public func pageViewController(
159 | _ pageViewController: UIPageViewController,
160 | viewControllerAfter viewController: UIViewController
161 | ) -> UIViewController? {
162 | let previewItemViewController = viewController as! PreviewItemViewController
163 | let afterIndex = previewItemViewController.index + 1
164 | let itemsCount = dataSource?.numberOfPreviewItems(in: self)
165 | guard let itemsCount else { return nil }
166 | guard afterIndex < itemsCount else { return nil }
167 | let item = dataSource?.previewController(
168 | self,
169 | previewItemAt: afterIndex
170 | )
171 | guard let item else { return nil }
172 | return PreviewItemViewController(item, index: afterIndex)
173 | }
174 | }
175 |
176 | extension PreviewController: UIPageViewControllerDelegate {
177 | public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
178 | let currentViewController = pageViewController.viewControllers?.first as? PreviewItemViewController
179 | if let currentViewController {
180 | currentPreviewItemIndex = currentViewController.index
181 | }
182 | }
183 | }
184 |
185 | extension PreviewController: PageViewControllerUIDelegate {
186 | func dismissActionTriggered() {
187 | internalNavigationController.setNavigationBarHidden(true, animated: true)
188 | internalNavigationController.setToolbarHidden(true, animated: true)
189 | dismiss(animated: true)
190 | }
191 |
192 | func presentActivityActionTriggered() {
193 | let item = dataSource?.previewController(self, previewItemAt: currentPreviewItemIndex)
194 | let configuration = item?.makeActivityItemsConfiguration()
195 | guard let configuration else { return }
196 | let vc = UIActivityViewController(
197 | activityItemsConfiguration: configuration
198 | )
199 | vc.popoverPresentationController?.sourceItem = pageViewController.navigationItem.rightBarButtonItem
200 | present(vc, animated: true)
201 | }
202 | }
203 |
204 | // MARK: utils
205 | extension PreviewController {
206 | internal var currentTransitionView: UIView? {
207 | guard let currentPreviewItem else { return nil }
208 | return delegate?.previewController(self, transitionViewFor: currentPreviewItem)
209 | }
210 |
211 | internal var topView: UIView? {
212 | internalNavigationController
213 | .topViewController?
214 | .view
215 | }
216 | }
217 |
218 | fileprivate final class Toolbar: UIToolbar {
219 | // TODO: self-resizing height
220 | // TODO: transparency background when has any item
221 | }
222 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/UI/PreviewControllerDataSource.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import QuickLook
3 |
4 | @MainActor
5 | public protocol PreviewControllerDataSource: AnyObject {
6 | func numberOfPreviewItems(in controller: PreviewController) -> Int
7 | func previewController(_ controller: PreviewController, previewItemAt index: Int) -> any PreviewItem
8 | }
9 |
10 |
--------------------------------------------------------------------------------
/Sources/MediaViewer/UI/PreviewControllerDelegate.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 |
3 | @MainActor
4 | public protocol PreviewControllerDelegate : AnyObject {
5 | func previewController(_ controller: PreviewController, transitionViewFor item: any PreviewItem) -> UIView?
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/MediaViewerBuiltins/Extensions/AVKit+.swift:
--------------------------------------------------------------------------------
1 | @preconcurrency import AVKit
2 |
3 | extension AVAsset {
4 | func makeThumbnailImage() async -> UIImage? {
5 | do {
6 | let duration = try await self.load(.duration)
7 | let middleTimeSeconds = duration.seconds / 2
8 | let middleTime = CMTimeMakeWithSeconds(
9 | middleTimeSeconds,
10 | preferredTimescale: duration.timescale
11 | )
12 | let generator = AVAssetImageGenerator(asset: self)
13 | generator.appliesPreferredTrackTransform = true
14 | let cgImage = try await withTaskCancellationHandler {
15 | try await withCheckedThrowingContinuation { (continuation) in
16 | generator.generateCGImageAsynchronously(
17 | for: middleTime,
18 | completionHandler: { (cgImage, _, error) in
19 | if let cgImage {
20 | continuation.resume(with: .success(cgImage))
21 | } else if let error {
22 | continuation.resume(with: .failure(error))
23 | }
24 | }
25 | )
26 | }
27 | } onCancel: {
28 | generator.cancelAllCGImageGeneration()
29 | }
30 | return UIImage(cgImage: cgImage)
31 | } catch {
32 | return nil
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/MediaViewerBuiltins/PreviewItem/AVPlayer+.swift:
--------------------------------------------------------------------------------
1 | public import AVKit
2 | import UIKit
3 | import MediaViewer
4 |
5 | extension AVPlayer: PreviewItem {
6 |
7 | public nonisolated func makeViewController() async -> UIViewController {
8 | _ = await publisher(for: \.status)
9 | .values
10 | .first(where: { $0 == .readyToPlay })
11 | return await PlayerPreviewItemViewController(player: self)
12 | }
13 |
14 | public func makeThumbnailViewController() -> UIViewController? {
15 | ThumbnailViewController { [weak self] in
16 | await self?.currentItem?.asset.makeThumbnailImage()
17 | }
18 | }
19 |
20 | public func makeActivityItemsConfiguration() -> (any UIActivityItemsConfigurationReading)? {
21 | nil
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/MediaViewerBuiltins/PreviewItem/UIImage+.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 | import MediaViewer
3 |
4 | extension UIImage: PreviewItem {
5 | public func makeViewController() async -> UIViewController {
6 | ImagePreviewItemViewController(image: self)
7 | }
8 |
9 | public func makeThumbnailViewController() -> UIViewController? {
10 | ThumbnailViewController(unfolding: { self })
11 | }
12 |
13 | public func makeActivityItemsConfiguration() -> (any UIActivityItemsConfigurationReading)? {
14 | let configuration = UIActivityItemsConfiguration(objects: [self])
15 | configuration.previewProvider = { (_, _, _) in
16 | NSItemProvider(object: self)
17 | }
18 | return configuration
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/MediaViewerBuiltins/PreviewItemView/ImagePreviewItemViewController.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 |
3 | public final class ImagePreviewItemViewController: UIViewController, UIScrollViewDelegate {
4 |
5 | let scrollView = UIScrollView()
6 | let imageView = UIImageView()
7 | let doubleTapGesture = UITapGestureRecognizer()
8 | let image: UIImage
9 |
10 | public init(image: UIImage) {
11 | self.image = image
12 | super.init(nibName: nil, bundle: nil)
13 | }
14 |
15 | required init?(coder: NSCoder) { fatalError() }
16 |
17 | public override func loadView() {
18 | view = scrollView
19 | }
20 |
21 | public override func viewDidLoad() {
22 | super.viewDidLoad()
23 | scrollView.delegate = self
24 | scrollView.showsVerticalScrollIndicator = false
25 | scrollView.showsHorizontalScrollIndicator = false
26 | scrollView.contentInsetAdjustmentBehavior = .never
27 |
28 | imageView.backgroundColor = .clear
29 | imageView.contentMode = .scaleAspectFit
30 | imageView.isUserInteractionEnabled = true
31 | scrollView.addSubview(imageView)
32 | imageView.translatesAutoresizingMaskIntoConstraints = false
33 | NSLayoutConstraint.activate([
34 | imageView.topAnchor.constraint(equalTo: scrollView.topAnchor),
35 | imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
36 | imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
37 | imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
38 | imageView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
39 | imageView.heightAnchor.constraint(equalTo: scrollView.heightAnchor)
40 | ])
41 |
42 | imageView.image = image
43 |
44 | doubleTapGesture.addTarget(self, action: #selector(handleDoubleTapGesture))
45 | doubleTapGesture.numberOfTapsRequired = 2
46 | imageView.addGestureRecognizer(doubleTapGesture)
47 |
48 | scrollView.minimumZoomScale = 1.0
49 | scrollView.maximumZoomScale = 3.0
50 | scrollView.zoomScale = 1.0
51 | }
52 |
53 | @objc private func handleDoubleTapGesture(_ gestureRecognizer: UITapGestureRecognizer) {
54 | if scrollView.zoomScale == scrollView.minimumZoomScale {
55 | let tapPoint = gestureRecognizer.location(in: imageView)
56 |
57 | let newZoomScale = scrollView.maximumZoomScale
58 | let xSize = scrollView.bounds.size.width / newZoomScale
59 | let ySize = scrollView.bounds.size.height / newZoomScale
60 | let zoomRect = CGRect(
61 | x: tapPoint.x - xSize / 2,
62 | y: tapPoint.y - ySize / 2,
63 | width: xSize,
64 | height: ySize
65 | )
66 |
67 | scrollView.zoom(to: zoomRect, animated: true)
68 | } else {
69 | scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
70 | }
71 | }
72 |
73 | public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
74 | imageView
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/Sources/MediaViewerBuiltins/PreviewItemView/PlayerPreviewItemViewController.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 | public import AVKit
3 | import os
4 |
5 | public final class PlayerPreviewItemViewController: UIViewController {
6 | let logger = Logger(
7 | subsystem: Bundle.main.bundleIdentifier!,
8 | category: #file
9 | )
10 |
11 | private let playerView = AVPlayerView()
12 |
13 | let player: AVPlayer
14 |
15 | public init(player: AVPlayer) {
16 | self.player = player
17 | super.init(nibName: nil, bundle: nil)
18 | }
19 |
20 | required init?(coder: NSCoder) { fatalError() }
21 |
22 | public override func loadView() {
23 | view = playerView
24 | }
25 |
26 | public override func viewDidLoad() {
27 | super.viewDidLoad()
28 | playerView.playerLayer.player = player
29 | }
30 |
31 | public override func viewDidAppear(_ animated: Bool) {
32 | super.viewDidAppear(animated)
33 | logger.debug("\(#function)")
34 | player.play()
35 | navigationController?.topViewController?.toolbarItems = [
36 | UIBarButtonItem.seekbar(player)
37 | ]
38 | }
39 |
40 | public override func viewWillDisappear(_ animated: Bool) {
41 | super.viewWillDisappear(animated)
42 | logger.debug("\(#function)")
43 | player.pause()
44 | navigationController?.topViewController?.toolbarItems = []
45 | }
46 | }
47 |
48 | fileprivate final class AVPlayerView: UIView {
49 | override class var layerClass: AnyClass { AVPlayerLayer.self }
50 | var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
51 | }
52 |
--------------------------------------------------------------------------------
/Sources/MediaViewerBuiltins/PreviewItemView/ThumbnailViewController.swift:
--------------------------------------------------------------------------------
1 | public import UIKit
2 |
3 | public final class ThumbnailViewController: UIViewController {
4 | let thumbnailImageView = UIImageView()
5 |
6 | public init(unfolding: @escaping () async -> UIImage?) {
7 | super.init(nibName: nil, bundle: nil)
8 | Task {
9 | thumbnailImageView.image = await unfolding()
10 | }
11 | }
12 |
13 | required init?(coder: NSCoder) {
14 | fatalError("init(coder:) has not been implemented")
15 | }
16 |
17 | public override func loadView() {
18 | view = thumbnailImageView
19 | }
20 |
21 | public override func viewDidLoad() {
22 | super.viewDidLoad()
23 | thumbnailImageView.contentMode = .scaleAspectFit
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/MediaViewerBuiltins/PreviewItemView/ToolbarItem/Seekbar.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import AVKit
3 |
4 | extension UIBarButtonItem {
5 | static func seekbar(_ player: AVPlayer) -> UIBarButtonItem {
6 | UIBarButtonItem(customView: Seekbar(player))
7 | }
8 | }
9 |
10 | import Combine
11 |
12 | fileprivate final class Seekbar: UIControl {
13 | let player: AVPlayer
14 | let playbackButton = UIButton(configuration: .playback())
15 | private let timeLabel = TimeLabel()
16 | let slider = UISlider()
17 | let sliderControlValue = PassthroughSubject()
18 | var timeObserver: Any = ()
19 | var durationObserver: AnyCancellable? = nil
20 | var playbackObserver: AnyCancellable? = nil
21 | var cancellables: Set = []
22 |
23 | init(_ player: AVPlayer) {
24 | self.player = player
25 | super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
26 |
27 | let stackView = UIStackView(arrangedSubviews: [
28 | playbackButton,
29 | slider,
30 | timeLabel
31 | ])
32 | stackView.axis = .horizontal
33 | stackView.spacing = UIStackView.spacingUseSystem
34 |
35 | stackView.translatesAutoresizingMaskIntoConstraints = false
36 | addSubview(stackView)
37 | NSLayoutConstraint.activate([
38 | playbackButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
39 | stackView.topAnchor.constraint(equalTo: topAnchor),
40 | bottomAnchor.constraint(equalTo: stackView.bottomAnchor),
41 | stackView.leftAnchor.constraint(equalTo: leftAnchor),
42 | rightAnchor.constraint(equalTo: stackView.rightAnchor),
43 | ])
44 |
45 | playbackButton.addAction(UIAction { [unowned self] _ in
46 | onTapPlaybackButton()
47 | }, for: .primaryActionTriggered)
48 |
49 | slider.addAction(UIAction { [unowned self] _ in
50 | sliderControlValue.send(slider.value)
51 | }, for: .primaryActionTriggered)
52 |
53 | sliderControlValue
54 | .throttle(for: 1, scheduler: DispatchQueue.main, latest: false)
55 | .sink { [weak self] _ in
56 | self?.player.pause()
57 | }.store(in: &cancellables)
58 |
59 | sliderControlValue
60 | .debounce(for: 1, scheduler: DispatchQueue.main)
61 | .sink { [weak self] value in
62 | let time = CMTime(
63 | seconds: Double(value),
64 | preferredTimescale: 600
65 | )
66 | self?.player.seek(to: time, completionHandler: { [weak self] _ in
67 | self?.player.play()
68 | })
69 | }.store(in: &cancellables)
70 |
71 | addObservePlayback()
72 |
73 | player.publisher(for: \.currentItem).sink { [weak self] currentItem in
74 | self?.addObserveDuration(currentItem)
75 | }.store(in: &cancellables)
76 |
77 | }
78 |
79 | override func didMoveToSuperview() {
80 | super.didMoveToSuperview()
81 | if superview != nil {
82 | addObserveTime()
83 | } else {
84 | removeObserveTime()
85 | }
86 | }
87 |
88 | required init?(coder: NSCoder) { fatalError() }
89 |
90 | func onTapPlaybackButton() {
91 | if player.isPaused {
92 | player.play()
93 | } else {
94 | player.pause()
95 | }
96 | }
97 |
98 | func addObservePlayback() {
99 | playbackObserver = player.publisher(for: \.rate).sink { [weak self] rate in
100 | let paused = rate == 0
101 | self?.playbackButton.configuration = .playback(paused: paused)
102 | }
103 | }
104 |
105 | func addObserveDuration(_ item: AVPlayerItem?) {
106 | if let item {
107 | durationObserver = item.publisher(for: \.duration).sink { [weak self] time in
108 | let maximumValue = Float(time.seconds)
109 | if maximumValue.isNormal {
110 | self?.slider.maximumValue = maximumValue
111 | self?.timeLabel.duration = time
112 | }
113 | }
114 | } else {
115 | durationObserver = nil
116 | slider.maximumValue = 0
117 | timeLabel.duration = nil
118 | }
119 |
120 | }
121 |
122 | func addObserveTime() {
123 | let interval = CMTime(
124 | seconds: CATransaction.animationDuration(),
125 | preferredTimescale: 600
126 | )
127 | timeObserver = player.addPeriodicTimeObserver(
128 | forInterval: interval,
129 | queue: DispatchQueue.main,
130 | using: { [weak self] time in
131 | MainActor.assumeIsolated {
132 | let value = Float(time.seconds)
133 | if value.isNormal {
134 | self?.slider.setValue(value, animated: false)
135 | self?.timeLabel.currentTime = time
136 | }
137 | }
138 | }
139 | )
140 | }
141 |
142 | func removeObserveTime() {
143 | player.removeTimeObserver(timeObserver)
144 | }
145 | }
146 |
147 | extension UIButton.Configuration {
148 | static func playback(paused: Bool = true) -> UIButton.Configuration {
149 | var configuration = UIButton.Configuration.plain()
150 | configuration.image = paused ? UIImage(systemName: "play.fill") : UIImage(systemName: "pause.fill")
151 | return configuration
152 | }
153 | }
154 |
155 | extension AVPlayer {
156 | var isPaused: Bool { rate == 0 }
157 | }
158 |
159 | fileprivate final class TimeLabel: UILabel {
160 | var currentTime: CMTime? = nil {
161 | didSet { update() }
162 | }
163 |
164 | var duration: CMTime? = nil {
165 | didSet { update() }
166 | }
167 |
168 | init() {
169 | super.init(frame: .null)
170 | font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
171 | textColor = .white
172 | update()
173 | }
174 |
175 | required init?(coder: NSCoder) {
176 | fatalError("init(coder:) has not been implemented")
177 | }
178 |
179 | func update() {
180 | let currentTimeText = string(for: currentTime)
181 | let durationText = string(for: duration)
182 | self.text = "\(currentTimeText) / \(durationText)"
183 | }
184 |
185 | func string(for time: CMTime?) -> String {
186 | guard let time else { return "--:--" }
187 | let duration = time.seconds
188 | let minutes = Int(duration / 60)
189 | let seconds = Int(duration.truncatingRemainder(dividingBy: 60))
190 | return String(format: "%02d:%02d", minutes, seconds)
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/Tests/MediaViewerTests/MediaViewerTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import MediaViewer
3 |
4 | final class MediaViewerTests: XCTestCase {
5 | func testExample() throws {
6 | // XCTest Documentation
7 | // https://developer.apple.com/documentation/xctest
8 |
9 | // Defining Test Cases and Test Methods
10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
11 | }
12 | }
13 |
--------------------------------------------------------------------------------