├── .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 | ![](https://github.com/noppefoxwolf/MediaViewer/blob/main/.github/example.gif) 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 | --------------------------------------------------------------------------------