├── .circleci └── config.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── Assets └── DINNextLTPro-Regular.otf ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Demo.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── GitHub ├── bakkenbaeck-logo.jpg ├── focus.gif ├── play.gif ├── rotation.gif ├── tv.gif ├── viewer-logo-2.jpg └── zoom.gif ├── LICENSE.md ├── Library ├── 0.jpg ├── 1.jpg ├── 2.jpg ├── 3.jpg ├── 4.jpg ├── 5.png ├── FooterView.swift ├── HeaderView.swift ├── Photo.swift ├── PhotoCell.swift ├── PhotosCollectionLayout.swift ├── PhotosController.swift ├── PopupController.swift └── clear.png ├── Package.swift ├── Podfile ├── README.md ├── Resources └── SharedAssets.xcassets │ ├── AppIcon.appiconset │ └── Contents.json │ ├── Brand Assets.brandassets │ ├── App Icon - Large.imagestack │ │ ├── Back.imagestacklayer │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Front.imagestacklayer │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── Middle.imagestacklayer │ │ │ ├── Content.imageset │ │ │ └── Contents.json │ │ │ └── Contents.json │ ├── App Icon - Small.imagestack │ │ ├── Back.imagestacklayer │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Front.imagestacklayer │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── Middle.imagestacklayer │ │ │ ├── Content.imageset │ │ │ └── Contents.json │ │ │ └── Contents.json │ ├── Contents.json │ ├── Top Shelf Image Wide.imageset │ │ └── Contents.json │ └── Top Shelf Image.imageset │ │ └── Contents.json │ ├── Contents.json │ ├── LaunchImage.launchimage │ └── Contents.json │ ├── delete.imageset │ ├── Contents.json │ ├── delete.png │ ├── delete@2x.png │ └── delete@3x.png │ ├── favorite.imageset │ ├── Contents.json │ ├── favorite.png │ ├── favorite@2x.png │ └── favorite@3x.png │ ├── menu.imageset │ ├── Contents.json │ ├── menu.png │ ├── menu@2x.png │ └── menu@3x.png │ └── video-indicator.imageset │ ├── Contents.json │ ├── video-indicator.png │ ├── video-indicator@2x.png │ └── video-indicator@3x.png ├── Source ├── DefaultHeaderView.swift ├── NSIndexPath+Contiguous.swift ├── PaginatedScrollView.swift ├── SlideshowView.swift ├── UIImage+CenteredFrame.swift ├── UIViewController+Window.swift ├── UIViewExtensions.swift ├── VideoProgressView.swift ├── VideoView.swift ├── Viewable.swift ├── ViewableController.swift ├── ViewableControllerContainer.swift ├── Viewer.xcassets │ ├── Contents.json │ ├── close.imageset │ │ ├── Contents.json │ │ ├── close.png │ │ ├── close@2x.png │ │ └── close@3x.png │ ├── dark-circle.imageset │ │ ├── Contents.json │ │ ├── dark-circle.png │ │ ├── dark-circle@2x.png │ │ └── dark-circle@3x.png │ ├── pause.imageset │ │ ├── Contents.json │ │ ├── pause.png │ │ ├── pause@2x.png │ │ └── pause@3x.png │ ├── play.imageset │ │ ├── Contents.json │ │ ├── play.png │ │ ├── play@2x.png │ │ └── play@3x.png │ ├── repeat.imageset │ │ ├── Contents.json │ │ ├── repeat.png │ │ ├── repeat@2x.png │ │ └── repeat@3x.png │ └── seek.imageset │ │ ├── Contents.json │ │ ├── seek.png │ │ ├── seek@2x.png │ │ └── seek@3x.png ├── ViewerAssets.swift └── ViewerController.swift ├── Tests ├── Info.plist └── Tests.swift ├── Viewer.podspec ├── Viewer ├── Info.plist └── Viewer.h ├── iOS ├── AppDelegate.swift ├── Base.lproj │ └── LaunchScreen.storyboard └── Info.plist └── tvOS ├── AppDelegate.swift └── Info.plist /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build-and-test: 4 | macos: 5 | xcode: "10.2.0" 6 | shell: /bin/bash --login -o pipefail 7 | steps: 8 | - checkout 9 | - run: xcodebuild -project Demo.xcodeproj -scheme "iOS" -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=12.2,name=iPhone X' build | xcpretty 10 | - run: xcodebuild -project Demo.xcodeproj -scheme "tvOS" -destination 'platform=tvOS Simulator,name=Apple TV,OS=12.2' build | xcpretty 11 | 12 | workflows: 13 | version: 2 14 | build-and-test: 15 | jobs: 16 | - build-and-test 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [3lvis] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **iOS Version (please complete the following information):** 30 | - [e.g. iOS 13] 31 | 32 | **Framework Version (please complete the following information):** 33 | - [e.g. Framework 6.0] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General issue 3 | about: General issues 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe the solution you'd like** 17 | A clear and concise description of what you want to happen. 18 | 19 | **Describe alternatives you've considered** 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | **Additional context** 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | Icon 6 | ._* 7 | .Spotlight-V100 8 | .Trashes 9 | 10 | # Xcode 11 | # 12 | build/ 13 | *.pbxuser 14 | !default.pbxuser 15 | *.mode1v3 16 | !default.mode1v3 17 | *.mode2v3 18 | !default.mode2v3 19 | *.perspectivev3 20 | !default.perspectivev3 21 | xcuserdata 22 | *.xccheckout 23 | *.moved-aside 24 | DerivedData 25 | *.hmap 26 | *.ipa 27 | *.xcuserstate 28 | *.xcscmblueprint 29 | *.gcno 30 | *.gcda 31 | 32 | # CocoaPods 33 | Pods 34 | *.lock 35 | 36 | # AppCode 37 | .idea 38 | -------------------------------------------------------------------------------- /Assets/DINNextLTPro-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Assets/DINNextLTPro-Regular.otf -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Check https://github.com/3lvis/Viewer/releases for more information. 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | GitHub Issues is for reporting bugs, discussing features and general feedback in **Viewer**. Be sure to check our [documentation](http://cocoadocs.org/docsets/Viewer), [FAQ](https://github.com/3lvis/Viewer/blob/master/README.md#faq) and [past issues](https://github.com/3lvis/Viewer/issues?state=closed) before opening any new issues. 2 | 3 | If you are posting about a crash in your application, a stack trace is helpful, but additional context, in the form of code and explanation, is necessary to be of any use. 4 | 5 | 6 | -------------------------------------------------------------------------------- /Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /GitHub/bakkenbaeck-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/GitHub/bakkenbaeck-logo.jpg -------------------------------------------------------------------------------- /GitHub/focus.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/GitHub/focus.gif -------------------------------------------------------------------------------- /GitHub/play.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/GitHub/play.gif -------------------------------------------------------------------------------- /GitHub/rotation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/GitHub/rotation.gif -------------------------------------------------------------------------------- /GitHub/tv.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/GitHub/tv.gif -------------------------------------------------------------------------------- /GitHub/viewer-logo-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/GitHub/viewer-logo-2.jpg -------------------------------------------------------------------------------- /GitHub/zoom.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/GitHub/zoom.gif -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Licensed under the **MIT** license 2 | 3 | > Copyright (c) 2016 Elvis Nuñez 4 | > 5 | > Permission is hereby granted, free of charge, to any person obtaining 6 | > a copy of this software and associated documentation files (the 7 | > "Software"), to deal in the Software without restriction, including 8 | > without limitation the rights to use, copy, modify, merge, publish, 9 | > distribute, sublicense, and/or sell copies of the Software, and to 10 | > permit persons to whom the Software is furnished to do so, subject to 11 | > the following conditions: 12 | > 13 | > The above copyright notice and this permission notice shall be 14 | > included in all copies or substantial portions of the Software. 15 | > 16 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | > EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | > MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | > IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | > CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | > TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | > SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Library/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Library/0.jpg -------------------------------------------------------------------------------- /Library/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Library/1.jpg -------------------------------------------------------------------------------- /Library/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Library/2.jpg -------------------------------------------------------------------------------- /Library/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Library/3.jpg -------------------------------------------------------------------------------- /Library/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Library/4.jpg -------------------------------------------------------------------------------- /Library/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Library/5.png -------------------------------------------------------------------------------- /Library/FooterView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol FooterViewDelegate: class { 4 | func footerView(_ footerView: FooterView, didPressFavoriteButton button: UIButton) 5 | func footerView(_ footerView: FooterView, didPressDeleteButton button: UIButton) 6 | } 7 | 8 | class FooterView: UIView { 9 | weak var viewDelegate: FooterViewDelegate? 10 | static let ButtonSize = CGFloat(50.0) 11 | 12 | lazy var favoriteButton: UIButton = { 13 | let image = UIImage(named: "favorite", in: Bundle(for: type(of: self)), compatibleWith: nil)! 14 | let button = UIButton(type: .custom) 15 | button.setImage(image, for: .normal) 16 | 17 | return button 18 | }() 19 | 20 | lazy var deleteButton: UIButton = { 21 | let image = UIImage(named: "delete", in: Bundle(for: type(of: self)), compatibleWith: nil)! 22 | let button = UIButton(type: .custom) 23 | button.setImage(image, for: .normal) 24 | 25 | return button 26 | }() 27 | 28 | override init(frame: CGRect) { 29 | super.init(frame: frame) 30 | 31 | self.addSubview(self.favoriteButton) 32 | self.addSubview(self.deleteButton) 33 | 34 | self.favoriteButton.addTarget(self, action: #selector(FooterView.favoriteAction(button:)), for: .touchUpInside) 35 | self.deleteButton.addTarget(self, action: #selector(FooterView.deleteAction(button:)), for: .touchUpInside) 36 | } 37 | 38 | required init?(coder _: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | override func layoutSubviews() { 43 | super.layoutSubviews() 44 | 45 | let favoriteSizes = self.widthForElementAtIndex(index: 0, totalElements: 2) 46 | self.favoriteButton.frame = CGRect(x: favoriteSizes.x, y: 0, width: favoriteSizes.width, height: FooterView.ButtonSize) 47 | 48 | let deleteSizes = self.widthForElementAtIndex(index: 1, totalElements: 2) 49 | self.deleteButton.frame = CGRect(x: deleteSizes.x, y: 0, width: deleteSizes.width, height: FooterView.ButtonSize) 50 | } 51 | 52 | func widthForElementAtIndex(index: Int, totalElements: Int) -> (x: CGFloat, width: CGFloat) { 53 | let bounds = UIScreen.main.bounds 54 | let singleFrame = bounds.width / CGFloat(totalElements) 55 | 56 | return (singleFrame * CGFloat(index), singleFrame) 57 | } 58 | 59 | @objc func favoriteAction(button: UIButton) { 60 | self.viewDelegate?.footerView(self, didPressFavoriteButton: button) 61 | } 62 | 63 | @objc func deleteAction(button: UIButton) { 64 | self.viewDelegate?.footerView(self, didPressDeleteButton: button) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Library/HeaderView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol HeaderViewDelegate: class { 4 | func headerView(_ headerView: HeaderView, didPressClearButton button: UIButton) 5 | func headerView(_ headerView: HeaderView, didPressMenuButton button: UIButton) 6 | } 7 | 8 | class HeaderView: UIView { 9 | weak var viewDelegate: HeaderViewDelegate? 10 | static let ButtonSize = CGFloat(50.0) 11 | static let TopMargin = CGFloat(15.0) 12 | 13 | lazy var clearButton: UIButton = { 14 | let image = UIImage.close 15 | let button = UIButton(type: .custom) 16 | button.setImage(image, for: .normal) 17 | button.addTarget(self, action: #selector(HeaderView.clearAction(button:)), for: .touchUpInside) 18 | 19 | return button 20 | }() 21 | 22 | lazy var menuButton: UIButton = { 23 | let image = UIImage(named: "menu", in: Bundle(for: type(of: self)), compatibleWith: nil)! 24 | let button = UIButton(type: .custom) 25 | button.setImage(image, for: .normal) 26 | button.addTarget(self, action: #selector(HeaderView.menuAction(button:)), for: .touchUpInside) 27 | 28 | return button 29 | }() 30 | 31 | override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | 34 | self.addSubview(self.clearButton) 35 | self.addSubview(self.menuButton) 36 | } 37 | 38 | required init?(coder _: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | override func layoutSubviews() { 43 | super.layoutSubviews() 44 | 45 | self.clearButton.frame = CGRect(x: 0, y: HeaderView.TopMargin, width: HeaderView.ButtonSize, height: HeaderView.ButtonSize) 46 | 47 | let x = UIScreen.main.bounds.size.width - HeaderView.ButtonSize 48 | self.menuButton.frame = CGRect(x: x, y: HeaderView.TopMargin, width: HeaderView.ButtonSize, height: HeaderView.ButtonSize) 49 | } 50 | 51 | @objc func clearAction(button: UIButton) { 52 | self.viewDelegate?.headerView(self, didPressClearButton: button) 53 | } 54 | 55 | @objc func menuAction(button: UIButton) { 56 | self.viewDelegate?.headerView(self, didPressMenuButton: button) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Library/Photo.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Photos 3 | 4 | class Section { 5 | var photos = [Photo]() 6 | let groupedDate: String 7 | 8 | init(groupedDate: String) { 9 | self.groupedDate = groupedDate 10 | } 11 | } 12 | 13 | class Photo: Viewable { 14 | var placeholder = UIImage() 15 | 16 | enum Size { 17 | case small 18 | case large 19 | } 20 | 21 | var type: ViewableType = .image 22 | var id: String 23 | var url: String? 24 | var assetID: String? 25 | 26 | init(id: String) { 27 | self.id = id 28 | } 29 | 30 | func media(_ completion: @escaping (_ image: UIImage?, _ error: NSError?) -> Void) { 31 | if let assetID = self.assetID { 32 | if let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject { 33 | Photo.image(for: asset) { image in 34 | completion(image, nil) 35 | } 36 | } 37 | } else { 38 | completion(self.placeholder, nil) 39 | } 40 | } 41 | 42 | static func constructRemoteElements() -> [Section] { 43 | var sections = [Section]() 44 | let numberOfSections = 20 45 | 46 | for sectionIndex in 0 ..< numberOfSections { 47 | var photos = [Photo]() 48 | for row in 0 ..< 10 { 49 | let photo = Photo(id: "\(sectionIndex)-\(row)") 50 | 51 | let index = Int(arc4random_uniform(6)) 52 | switch index { 53 | case 0: 54 | photo.placeholder = UIImage(named: "0.jpg", in: Bundle(for: Photo.self), compatibleWith: nil)! 55 | break 56 | case 1: 57 | photo.placeholder = UIImage(named: "1.jpg", in: Bundle(for: Photo.self), compatibleWith: nil)! 58 | break 59 | case 2: 60 | photo.placeholder = UIImage(named: "2.jpg", in: Bundle(for: Photo.self), compatibleWith: nil)! 61 | break 62 | case 3: 63 | photo.placeholder = UIImage(named: "3.jpg", in: Bundle(for: Photo.self), compatibleWith: nil)! 64 | break 65 | case 4: 66 | photo.placeholder = UIImage(named: "4.jpg", in: Bundle(for: Photo.self), compatibleWith: nil)! 67 | break 68 | case 5: 69 | photo.placeholder = UIImage(named: "5.png", in: Bundle(for: Photo.self), compatibleWith: nil)! 70 | photo.url = "http://techslides.com/demos/sample-videos/small.mp4" 71 | photo.type = .video 72 | default: break 73 | } 74 | photos.append(photo) 75 | } 76 | 77 | let groupedDate = "\(sectionIndex)-12-2016" 78 | let section = Section(groupedDate: groupedDate) 79 | section.photos = photos 80 | sections.append(section) 81 | } 82 | 83 | return sections 84 | } 85 | 86 | static func constructLocalElements() -> [Section] { 87 | var sections = [Section]() 88 | 89 | let fetchOptions = PHFetchOptions() 90 | let authorizationStatus = PHPhotoLibrary.authorizationStatus() 91 | 92 | guard authorizationStatus == .authorized else { fatalError("Camera Roll not authorized") } 93 | 94 | let fetchResult = PHAsset.fetchAssets(with: fetchOptions) 95 | if fetchResult.count > 0 { 96 | fetchResult.enumerateObjects({ asset, index, _ in 97 | let groupedDate = asset.creationDate?.groupedDateString() ?? "" 98 | var foundSection = Section(groupedDate: groupedDate) 99 | var foundIndex: Int? 100 | for (index, section) in sections.enumerated() { 101 | if section.groupedDate == groupedDate { 102 | foundSection = section 103 | foundIndex = index 104 | } 105 | } 106 | 107 | let photo = Photo(id: UUID().uuidString) 108 | photo.assetID = asset.localIdentifier 109 | 110 | if asset.duration > 0 { 111 | photo.type = .video 112 | } 113 | 114 | foundSection.photos.append(photo) 115 | if let foundIndex = foundIndex { 116 | sections[foundIndex] = foundSection 117 | } else { 118 | sections.append(foundSection) 119 | } 120 | }) 121 | } 122 | 123 | return sections.reversed() 124 | } 125 | 126 | static func thumbnail(for asset: PHAsset) -> UIImage? { 127 | let imageManager = PHImageManager.default() 128 | let requestOptions = PHImageRequestOptions() 129 | requestOptions.isNetworkAccessAllowed = true 130 | requestOptions.isSynchronous = true 131 | requestOptions.deliveryMode = .fastFormat 132 | requestOptions.resizeMode = .fast 133 | 134 | var returnedImage: UIImage? 135 | let scaleFactor = UIScreen.main.scale 136 | let itemSize = CGSize(width: 150, height: 150) 137 | let targetSize = CGSize(width: itemSize.width * scaleFactor, height: itemSize.height * scaleFactor) 138 | imageManager.requestImage(for: asset, targetSize: targetSize, contentMode: .aspectFit, options: requestOptions) { image, _ in 139 | // WARNING: This could fail if your phone doesn't have enough storage. Since the photo is probably 140 | // stored in iCloud downloading it to your phone will take most of the space left making this feature fail. 141 | // guard let image = image else { fatalError("Couldn't get photo data for asset \(asset)") } 142 | 143 | returnedImage = image 144 | } 145 | 146 | return returnedImage 147 | } 148 | 149 | static func image(for asset: PHAsset, completion: @escaping (_ image: UIImage?) -> Void) { 150 | let imageManager = PHImageManager.default() 151 | let requestOptions = PHImageRequestOptions() 152 | requestOptions.isNetworkAccessAllowed = true 153 | requestOptions.isSynchronous = false 154 | requestOptions.deliveryMode = .opportunistic 155 | requestOptions.resizeMode = .fast 156 | 157 | let bounds = UIScreen.main.bounds.size 158 | let targetSize = CGSize(width: bounds.width * 2, height: bounds.height * 2) 159 | imageManager.requestImage(for: asset, targetSize: targetSize, contentMode: .aspectFit, options: requestOptions) { image, _ in 160 | // WARNING: This could fail if your phone doesn't have enough storage. Since the photo is probably 161 | // stored in iCloud downloading it to your phone will take most of the space left making this feature fail. 162 | // guard let image = image else { fatalError("Couldn't get photo data for asset \(asset)") } 163 | DispatchQueue.main.async { 164 | completion(image) 165 | } 166 | } 167 | } 168 | 169 | static func checkAuthorizationStatus(completion: @escaping (_ success: Bool) -> Void) { 170 | let currentStatus = PHPhotoLibrary.authorizationStatus() 171 | 172 | guard currentStatus != .authorized else { 173 | completion(true) 174 | return 175 | } 176 | 177 | PHPhotoLibrary.requestAuthorization { authorizationStatus in 178 | DispatchQueue.main.async { 179 | if authorizationStatus == .denied { 180 | completion(false) 181 | } else if authorizationStatus == .authorized { 182 | completion(true) 183 | } 184 | } 185 | } 186 | } 187 | } 188 | 189 | extension Date { 190 | 191 | func groupedDateString() -> String { 192 | let noTimeDate = Calendar.current.startOfDay(for: self) 193 | 194 | let dateFormatter = DateFormatter() 195 | dateFormatter.dateFormat = "yyyy-MM-dd" 196 | let groupedDateString = dateFormatter.string(from: noTimeDate) 197 | 198 | return groupedDateString 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /Library/PhotoCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Photos 3 | 4 | class PhotoCell: UICollectionViewCell { 5 | static let Identifier = "PhotoCellIdentifier" 6 | 7 | lazy var imageView: UIImageView = { 8 | let view = UIImageView() 9 | view.contentMode = .scaleAspectFill 10 | 11 | #if os(iOS) 12 | view.clipsToBounds = true 13 | #else 14 | view.clipsToBounds = false 15 | view.adjustsImageWhenAncestorFocused = true 16 | #endif 17 | 18 | return view 19 | }() 20 | 21 | override init(frame: CGRect) { 22 | super.init(frame: frame) 23 | 24 | self.backgroundColor = .black 25 | self.addSubview(self.imageView) 26 | self.addSubview(self.videoIndicator) 27 | } 28 | 29 | required init?(coder _: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | lazy var videoIndicator: UIImageView = { 34 | let view = UIImageView() 35 | view.image = UIImage(named: "video-indicator", in: Bundle(for: type(of: self)), compatibleWith: nil)! 36 | view.isHidden = true 37 | view.contentMode = .center 38 | 39 | return view 40 | }() 41 | 42 | var photo: Photo? { 43 | didSet { 44 | guard let photo = self.photo else { 45 | self.imageView.image = nil 46 | return 47 | } 48 | 49 | self.videoIndicator.isHidden = photo.type == .image 50 | 51 | if let assetID = photo.assetID, let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject, let image = Photo.thumbnail(for: asset) { 52 | self.imageView.image = image 53 | } else { 54 | self.imageView.image = photo.placeholder 55 | } 56 | } 57 | } 58 | 59 | override func layoutSubviews() { 60 | super.layoutSubviews() 61 | 62 | self.videoIndicator.frame = CGRect(x: 0, y: 0, width: self.frame.width, height: self.frame.width) 63 | self.imageView.frame = CGRect(x: 0, y: 0, width: self.frame.width, height: self.frame.height) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Library/PhotosCollectionLayout.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class PhotosCollectionLayout: UICollectionViewFlowLayout { 4 | static let headerSize = CGFloat(69) 5 | class var numberOfColumns: Int { 6 | 7 | #if os(iOS) 8 | var isPortrait: Bool = false 9 | switch UIDevice.current.orientation { 10 | case .portrait, .portraitUpsideDown, .unknown, .faceUp, .faceDown: 11 | isPortrait = true 12 | case .landscapeLeft, .landscapeRight: 13 | isPortrait = false 14 | @unknown default: 15 | break 16 | } 17 | 18 | var numberOfColumns = 0 19 | if UIDevice.current.userInterfaceIdiom == .phone { 20 | numberOfColumns = isPortrait ? 3 : 6 21 | } else { 22 | numberOfColumns = isPortrait ? 5 : 8 23 | } 24 | 25 | return numberOfColumns 26 | #else 27 | return 6 28 | #endif 29 | } 30 | 31 | init(isGroupedByDay: Bool = true) { 32 | super.init() 33 | 34 | let bounds = UIScreen.main.bounds 35 | self.itemSize = PhotosCollectionLayout.itemSize() 36 | self.headerReferenceSize = CGSize(width: bounds.size.width, height: PhotosCollectionLayout.headerSize) 37 | 38 | #if os(iOS) 39 | self.minimumLineSpacing = 1 40 | self.minimumInteritemSpacing = 1 41 | #else 42 | let margin = CGFloat(25) 43 | self.minimumLineSpacing = 50 44 | self.minimumInteritemSpacing = margin 45 | self.sectionInset = UIEdgeInsets(top: margin, left: 90, bottom: margin, right: 90) 46 | #endif 47 | } 48 | 49 | required init?(coder _: NSCoder) { 50 | fatalError("init(coder:) has not been implemented") 51 | } 52 | 53 | class func itemSize() -> CGSize { 54 | #if os(iOS) 55 | let bounds = UIScreen.main.bounds 56 | let size = (bounds.width - (CGFloat(PhotosCollectionLayout.numberOfColumns) - 1)) / CGFloat(PhotosCollectionLayout.numberOfColumns) 57 | return CGSize(width: size, height: size) 58 | #else 59 | return CGSize(width: 260, height: 260) 60 | #endif 61 | } 62 | 63 | func updateItemSize() { 64 | self.itemSize = PhotosCollectionLayout.itemSize() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Library/PhotosController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum DataSourceType { 4 | case local 5 | case remote 6 | } 7 | 8 | class PhotosController: UICollectionViewController { 9 | var dataSourceType: DataSourceType 10 | var viewerController: ViewerController? 11 | var optionsController: OptionsController? 12 | 13 | func numberOfItems() -> Int { 14 | var count = 0 15 | for i in 0 ..< self.sections.count { 16 | let section = self.sections[i] 17 | count += section.photos.count 18 | } 19 | return count 20 | } 21 | var sections = [Section]() 22 | 23 | init(dataSourceType: DataSourceType) { 24 | self.dataSourceType = dataSourceType 25 | 26 | super.init(collectionViewLayout: PhotosCollectionLayout()) 27 | } 28 | 29 | required init?(coder _: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | override func viewDidLoad() { 34 | super.viewDidLoad() 35 | self.collectionView?.backgroundColor = .white 36 | self.collectionView?.register(PhotoCell.self, forCellWithReuseIdentifier: PhotoCell.Identifier) 37 | 38 | switch self.dataSourceType { 39 | case .local: 40 | Photo.checkAuthorizationStatus { success in 41 | if success { 42 | self.sections = Photo.constructLocalElements() 43 | self.collectionView?.reloadData() 44 | } 45 | } 46 | case .remote: 47 | self.sections = Photo.constructRemoteElements() 48 | self.collectionView?.reloadData() 49 | } 50 | 51 | #if os(tvOS) 52 | let playPauseTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.playPause(gesture:))) 53 | playPauseTapRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.playPause.rawValue)] 54 | self.collectionView?.addGestureRecognizer(playPauseTapRecognizer) 55 | 56 | // Workaround for a bug where the collectionView won't select an item after dismissing the Viewer. 57 | let selectTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.select(gesture:))) 58 | selectTapRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.select.rawValue)] 59 | self.collectionView?.addGestureRecognizer(selectTapRecognizer) 60 | #endif 61 | } 62 | 63 | #if os(tvOS) 64 | @objc func select(gesture: UITapGestureRecognizer) { 65 | if let focusedCell = UIScreen.main.focusedView as? UICollectionViewCell { 66 | if let indexPath = self.collectionView?.indexPath(for: focusedCell) { 67 | self.collectionView(self.collectionView!, didSelectItemAt: indexPath) 68 | } 69 | } 70 | } 71 | 72 | @objc func playPause(gesture: UITapGestureRecognizer) { 73 | guard gesture.state == .ended else { return } 74 | guard let collectionView = self.collectionView else { return } 75 | 76 | if let focusedCell = UIScreen.main.focusedView as? UICollectionViewCell { 77 | if let indexPath = collectionView.indexPath(for: focusedCell) { 78 | self.viewerController = ViewerController(initialIndexPath: indexPath, collectionView: collectionView, isSlideshow: true) 79 | self.viewerController!.dataSource = self 80 | self.viewerController!.delegate = self 81 | self.present(self.viewerController!, animated: false, completion: nil) 82 | } 83 | } 84 | } 85 | #endif 86 | 87 | #if os(tvOS) 88 | override var preferredFocusEnvironments: [UIFocusEnvironment] { 89 | var environments = [UIFocusEnvironment]() 90 | 91 | if let indexPath = self.viewerController?.currentIndexPath { 92 | if let cell = self.collectionView?.cellForItem(at: indexPath) { 93 | environments.append(cell) 94 | } 95 | } 96 | 97 | return environments 98 | } 99 | #endif 100 | 101 | func alertController(with title: String) -> UIAlertController { 102 | let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert) 103 | alertController.addAction(UIAlertAction(title: "Dismiss", style: .default, handler: nil)) 104 | 105 | return alertController 106 | } 107 | 108 | func photo(at indexPath: IndexPath) -> Photo { 109 | let section = self.sections[indexPath.section] 110 | let photo = section.photos[indexPath.row] 111 | 112 | return photo 113 | } 114 | } 115 | 116 | extension PhotosController { 117 | 118 | override func numberOfSections(in _: UICollectionView) -> Int { 119 | return self.sections.count 120 | } 121 | 122 | override func collectionView(_: UICollectionView, numberOfItemsInSection section: Int) -> Int { 123 | let section = self.sections[section] 124 | 125 | return section.photos.count 126 | } 127 | 128 | override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 129 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoCell.Identifier, for: indexPath) as! PhotoCell 130 | cell.photo = self.photo(at: indexPath) 131 | cell.photo?.placeholder = cell.imageView.image ?? UIImage() 132 | 133 | return cell 134 | } 135 | 136 | public override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 137 | guard let collectionView = self.collectionView else { return } 138 | 139 | self.viewerController = ViewerController(initialIndexPath: indexPath, collectionView: collectionView) 140 | self.viewerController!.dataSource = self 141 | self.viewerController!.delegate = self 142 | 143 | #if os(iOS) 144 | let headerView = HeaderView() 145 | headerView.viewDelegate = self 146 | self.viewerController?.headerView = headerView 147 | let footerView = FooterView() 148 | footerView.viewDelegate = self 149 | self.viewerController?.footerView = footerView 150 | #endif 151 | 152 | self.present(self.viewerController!, animated: false, completion: nil) 153 | } 154 | 155 | #if os(tvOS) 156 | public override func collectionView(_: UICollectionView, canFocusItemAt _: IndexPath) -> Bool { 157 | let isViewerVisible = self.viewerController?.isPresented ?? false 158 | let shouldFocusCells = !isViewerVisible 159 | 160 | return shouldFocusCells 161 | } 162 | #endif 163 | } 164 | 165 | extension PhotosController: ViewerControllerDataSource { 166 | 167 | func numberOfItemsInViewerController(_: ViewerController) -> Int { 168 | return self.numberOfItems() 169 | } 170 | 171 | func viewerController(_: ViewerController, viewableAt indexPath: IndexPath) -> Viewable { 172 | let viewable = self.photo(at: indexPath) 173 | if let cell = self.collectionView?.cellForItem(at: indexPath) as? PhotoCell, let placeholder = cell.imageView.image { 174 | viewable.placeholder = placeholder 175 | } 176 | 177 | return viewable 178 | } 179 | } 180 | 181 | extension PhotosController: ViewerControllerDelegate { 182 | func viewerController(_: ViewerController, didChangeFocusTo _: IndexPath) {} 183 | 184 | func viewerControllerDidDismiss(_: ViewerController) { 185 | #if os(tvOS) 186 | // Used to refocus after swiping a few items in fullscreen. 187 | self.setNeedsFocusUpdate() 188 | self.updateFocusIfNeeded() 189 | #endif 190 | } 191 | 192 | func viewerController(_: ViewerController, didFailDisplayingViewableAt _: IndexPath, error _: NSError) { 193 | 194 | } 195 | 196 | func viewerController(_ viewerController: ViewerController, didLongPressViewableAt indexPath: IndexPath) { 197 | print("didLongPressViewableAt: \(indexPath)") 198 | } 199 | } 200 | 201 | extension PhotosController: OptionsControllerDelegate { 202 | 203 | func optionsController(optionsController _: OptionsController, didSelectOption _: String) { 204 | self.optionsController?.dismiss(animated: true) { 205 | self.viewerController?.dismiss(nil) 206 | } 207 | } 208 | } 209 | 210 | extension PhotosController: HeaderViewDelegate { 211 | 212 | func headerView(_: HeaderView, didPressClearButton _: UIButton) { 213 | self.viewerController?.dismiss(nil) 214 | } 215 | 216 | func headerView(_: HeaderView, didPressMenuButton button: UIButton) { 217 | let rect = CGRect(x: 0, y: 0, width: 50, height: 50) 218 | self.optionsController = OptionsController(sourceView: button, sourceRect: rect) 219 | self.optionsController!.delegate = self 220 | self.viewerController?.present(self.optionsController!, animated: true, completion: nil) 221 | } 222 | } 223 | 224 | extension PhotosController: FooterViewDelegate { 225 | 226 | func footerView(_: FooterView, didPressFavoriteButton _: UIButton) { 227 | let alertController = self.alertController(with: "Favorite pressed") 228 | self.viewerController?.present(alertController, animated: true, completion: nil) 229 | } 230 | 231 | func footerView(_: FooterView, didPressDeleteButton _: UIButton) { 232 | let alertController = self.alertController(with: "Delete pressed") 233 | self.viewerController?.present(alertController, animated: true, completion: nil) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /Library/PopupController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol OptionsControllerDelegate: class { 4 | func optionsController(optionsController: OptionsController, didSelectOption option: String) 5 | } 6 | 7 | class OptionsController: UITableViewController { 8 | static let CellIdentifier = "CellIdentifier" 9 | static let PopoverSize = CGFloat(179) 10 | weak var delegate: OptionsControllerDelegate? 11 | static let RowHeight = CGFloat(60.0) 12 | fileprivate var options = ["First option", "Second option", "Third option"] 13 | 14 | init(sourceView: UIView, sourceRect: CGRect) { 15 | super.init(nibName: nil, bundle: nil) 16 | 17 | #if os(iOS) 18 | self.modalPresentationStyle = .popover 19 | self.popoverPresentationController?.delegate = self 20 | #endif 21 | self.preferredContentSize = CGSize(width: OptionsController.PopoverSize, height: OptionsController.PopoverSize) 22 | self.popoverPresentationController?.backgroundColor = .white 23 | self.popoverPresentationController?.permittedArrowDirections = [.any] 24 | self.popoverPresentationController?.sourceView = sourceView 25 | self.popoverPresentationController?.sourceRect = sourceRect 26 | } 27 | 28 | required init?(coder _: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | 35 | self.tableView.rowHeight = OptionsController.RowHeight 36 | self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: OptionsController.CellIdentifier) 37 | } 38 | } 39 | 40 | #if os(iOS) 41 | extension OptionsController: UIPopoverPresentationControllerDelegate { 42 | 43 | func adaptivePresentationStyleForPresentationController(controller _: UIPresentationController) -> UIModalPresentationStyle { 44 | return .none 45 | } 46 | } 47 | #endif 48 | 49 | extension OptionsController { 50 | 51 | override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { 52 | return self.options.count 53 | } 54 | 55 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 56 | let cell = tableView.dequeueReusableCell(withIdentifier: OptionsController.CellIdentifier, for: indexPath) 57 | 58 | let option = self.options[indexPath.row] 59 | cell.textLabel?.text = option 60 | 61 | return cell 62 | } 63 | 64 | public override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { 65 | let option = self.options[indexPath.row] 66 | self.delegate?.optionsController(optionsController: self, didSelectOption: option) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Library/clear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Library/clear.png -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // Licensed under the **MIT** license 2 | // Copyright (c) 2016 Elvis Nuñez 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining 5 | // a copy of this software and associated documentation files (the 6 | // "Software"), to deal in the Software without restriction, including 7 | // without limitation the rights to use, copy, modify, merge, publish, 8 | // distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to 10 | // the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be 13 | // included in all copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 19 | // CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 20 | // TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 21 | // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | import PackageDescription 24 | 25 | let package = Package( 26 | name: "Viewer" 27 | ) 28 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Podfile -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Viewer](https://raw.githubusercontent.com/3lvis/Viewer/master/GitHub/viewer-logo-2.jpg) 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | ## Table of Contents 20 | 21 | * [Features](#features) 22 | * [Focus](#focus) 23 | * [Browse](#browse) 24 | * [Rotation](#rotation) 25 | * [Zoom](#zoom) 26 | * [tvOS](#tvos) 27 | * [Setup](#setup) 28 | * [Installation](#installation) 29 | * [License](#license) 30 | * [Author](#author) 31 | 32 | ## Features 33 | 34 | ### Focus 35 | 36 | Select an image to enter into lightbox mode. 37 | 38 |

39 | 40 |

41 | 42 | ### Browse 43 | 44 | Open an image or video to browse. 45 | 46 |

47 | 48 |

49 | 50 | ### Rotation 51 | 52 | Portrait or landscape, it just works. 53 | 54 |

55 | 56 |

57 | 58 | ### Zoom 59 | 60 | Pinch-to-zoom works seamlessly in images. 61 | 62 |

63 | 64 |

65 | 66 | ### tvOS 67 | 68 | Support for the Apple TV. 69 | 70 |

71 | 72 |

73 | 74 | ## Setup 75 | 76 | You'll need a collection of items that comform to the [Viewable protocol](https://github.com/3lvis/Viewer/blob/master/Source/Viewable.swift). Then, from your UICollectionView: 77 | 78 | ```swift 79 | import Viewer 80 | 81 | override public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 82 | guard let collectionView = self.collectionView else { return } 83 | 84 | let viewerController = ViewerController(initialIndexPath: indexPath, collectionView: collectionView) 85 | viewerController.dataSource = self 86 | presentViewController(viewerController, animated: false, completion: nil) 87 | } 88 | 89 | extension CollectionController: ViewerControllerDataSource { 90 | func viewerController(_ viewerController: ViewerController, viewableAt indexPath: IndexPath) -> Viewable { 91 | return photos[indexPath.row] 92 | } 93 | } 94 | ``` 95 | 96 | ## Installation 97 | 98 | ### CocoaPods 99 | 100 | ```ruby 101 | pod 'Viewer' 102 | ``` 103 | 104 | ### Carthage 105 | 106 | ```ruby 107 | github "3lvis/Viewer" 108 | ``` 109 | 110 | ## License 111 | 112 | **Viewer** is available under the MIT license. See the [LICENSE](/LICENSE.md) file for more info. 113 | -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "1x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "2x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "29x29", 26 | "scale" : "3x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "40x40", 36 | "scale" : "3x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "57x57", 41 | "scale" : "1x" 42 | }, 43 | { 44 | "idiom" : "iphone", 45 | "size" : "57x57", 46 | "scale" : "2x" 47 | }, 48 | { 49 | "idiom" : "iphone", 50 | "size" : "60x60", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "iphone", 55 | "size" : "60x60", 56 | "scale" : "3x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "20x20", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "20x20", 66 | "scale" : "2x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "29x29", 71 | "scale" : "1x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "29x29", 76 | "scale" : "2x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "40x40", 81 | "scale" : "1x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "40x40", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ipad", 90 | "size" : "50x50", 91 | "scale" : "1x" 92 | }, 93 | { 94 | "idiom" : "ipad", 95 | "size" : "50x50", 96 | "scale" : "2x" 97 | }, 98 | { 99 | "idiom" : "ipad", 100 | "size" : "72x72", 101 | "scale" : "1x" 102 | }, 103 | { 104 | "idiom" : "ipad", 105 | "size" : "72x72", 106 | "scale" : "2x" 107 | }, 108 | { 109 | "idiom" : "ipad", 110 | "size" : "76x76", 111 | "scale" : "1x" 112 | }, 113 | { 114 | "idiom" : "ipad", 115 | "size" : "76x76", 116 | "scale" : "2x" 117 | }, 118 | { 119 | "idiom" : "ipad", 120 | "size" : "83.5x83.5", 121 | "scale" : "2x" 122 | }, 123 | { 124 | "idiom" : "ios-marketing", 125 | "size" : "1024x1024", 126 | "scale" : "1x" 127 | } 128 | ], 129 | "info" : { 130 | "version" : 1, 131 | "author" : "xcode" 132 | } 133 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "version" : 1, 14 | "author" : "xcode" 15 | } 16 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Large.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "layers" : [ 3 | { 4 | "filename" : "Front.imagestacklayer" 5 | }, 6 | { 7 | "filename" : "Middle.imagestacklayer" 8 | }, 9 | { 10 | "filename" : "Back.imagestacklayer" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "version" : 1, 14 | "author" : "xcode" 15 | } 16 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "version" : 1, 14 | "author" : "xcode" 15 | } 16 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "version" : 1, 14 | "author" : "xcode" 15 | } 16 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Small.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "layers" : [ 3 | { 4 | "filename" : "Front.imagestacklayer" 5 | }, 6 | { 7 | "filename" : "Middle.imagestacklayer" 8 | }, 9 | { 10 | "filename" : "Back.imagestacklayer" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "version" : 1, 14 | "author" : "xcode" 15 | } 16 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "version" : 1, 14 | "author" : "xcode" 15 | } 16 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "assets" : [ 3 | { 4 | "size" : "1280x768", 5 | "idiom" : "tv", 6 | "filename" : "App Icon - Large.imagestack", 7 | "role" : "primary-app-icon" 8 | }, 9 | { 10 | "size" : "400x240", 11 | "idiom" : "tv", 12 | "filename" : "App Icon - Small.imagestack", 13 | "role" : "primary-app-icon" 14 | }, 15 | { 16 | "size" : "2320x720", 17 | "idiom" : "tv", 18 | "filename" : "Top Shelf Image Wide.imageset", 19 | "role" : "top-shelf-image-wide" 20 | }, 21 | { 22 | "size" : "1920x720", 23 | "idiom" : "tv", 24 | "filename" : "Top Shelf Image.imageset", 25 | "role" : "top-shelf-image" 26 | } 27 | ], 28 | "info" : { 29 | "version" : 1, 30 | "author" : "xcode" 31 | } 32 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/Top Shelf Image Wide.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "version" : 1, 14 | "author" : "xcode" 15 | } 16 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Brand Assets.brandassets/Top Shelf Image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "version" : 1, 14 | "author" : "xcode" 15 | } 16 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/LaunchImage.launchimage/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "orientation" : "portrait", 5 | "idiom" : "iphone", 6 | "extent" : "full-screen", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "orientation" : "portrait", 11 | "idiom" : "iphone", 12 | "extent" : "full-screen", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "orientation" : "portrait", 17 | "idiom" : "iphone", 18 | "extent" : "full-screen", 19 | "subtype" : "retina4", 20 | "scale" : "2x" 21 | }, 22 | { 23 | "orientation" : "portrait", 24 | "idiom" : "ipad", 25 | "extent" : "to-status-bar", 26 | "scale" : "1x" 27 | }, 28 | { 29 | "orientation" : "portrait", 30 | "idiom" : "ipad", 31 | "extent" : "full-screen", 32 | "scale" : "1x" 33 | }, 34 | { 35 | "orientation" : "landscape", 36 | "idiom" : "ipad", 37 | "extent" : "to-status-bar", 38 | "scale" : "1x" 39 | }, 40 | { 41 | "orientation" : "landscape", 42 | "idiom" : "ipad", 43 | "extent" : "full-screen", 44 | "scale" : "1x" 45 | }, 46 | { 47 | "orientation" : "portrait", 48 | "idiom" : "ipad", 49 | "extent" : "to-status-bar", 50 | "scale" : "2x" 51 | }, 52 | { 53 | "orientation" : "portrait", 54 | "idiom" : "ipad", 55 | "extent" : "full-screen", 56 | "scale" : "2x" 57 | }, 58 | { 59 | "orientation" : "landscape", 60 | "idiom" : "ipad", 61 | "extent" : "to-status-bar", 62 | "scale" : "2x" 63 | }, 64 | { 65 | "orientation" : "landscape", 66 | "idiom" : "ipad", 67 | "extent" : "full-screen", 68 | "scale" : "2x" 69 | }, 70 | { 71 | "orientation" : "portrait", 72 | "idiom" : "iphone", 73 | "extent" : "full-screen", 74 | "minimum-system-version" : "8.0", 75 | "subtype" : "736h", 76 | "scale" : "3x" 77 | }, 78 | { 79 | "orientation" : "landscape", 80 | "idiom" : "iphone", 81 | "extent" : "full-screen", 82 | "minimum-system-version" : "8.0", 83 | "subtype" : "736h", 84 | "scale" : "3x" 85 | }, 86 | { 87 | "orientation" : "portrait", 88 | "idiom" : "iphone", 89 | "extent" : "full-screen", 90 | "minimum-system-version" : "8.0", 91 | "subtype" : "667h", 92 | "scale" : "2x" 93 | }, 94 | { 95 | "orientation" : "portrait", 96 | "idiom" : "iphone", 97 | "extent" : "full-screen", 98 | "minimum-system-version" : "7.0", 99 | "scale" : "2x" 100 | }, 101 | { 102 | "orientation" : "portrait", 103 | "idiom" : "iphone", 104 | "extent" : "full-screen", 105 | "minimum-system-version" : "7.0", 106 | "subtype" : "retina4", 107 | "scale" : "2x" 108 | }, 109 | { 110 | "orientation" : "portrait", 111 | "idiom" : "ipad", 112 | "extent" : "full-screen", 113 | "minimum-system-version" : "7.0", 114 | "scale" : "1x" 115 | }, 116 | { 117 | "orientation" : "landscape", 118 | "idiom" : "ipad", 119 | "extent" : "full-screen", 120 | "minimum-system-version" : "7.0", 121 | "scale" : "1x" 122 | }, 123 | { 124 | "orientation" : "portrait", 125 | "idiom" : "ipad", 126 | "extent" : "full-screen", 127 | "minimum-system-version" : "7.0", 128 | "scale" : "2x" 129 | }, 130 | { 131 | "orientation" : "landscape", 132 | "idiom" : "ipad", 133 | "extent" : "full-screen", 134 | "minimum-system-version" : "7.0", 135 | "scale" : "2x" 136 | } 137 | ], 138 | "info" : { 139 | "version" : 1, 140 | "author" : "xcode" 141 | } 142 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/delete.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "delete.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "delete@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "delete@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/delete.imageset/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/delete.imageset/delete.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/delete.imageset/delete@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/delete.imageset/delete@2x.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/delete.imageset/delete@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/delete.imageset/delete@3x.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/favorite.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "favorite.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "favorite@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "favorite@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/favorite.imageset/favorite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/favorite.imageset/favorite.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/favorite.imageset/favorite@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/favorite.imageset/favorite@2x.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/favorite.imageset/favorite@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/favorite.imageset/favorite@3x.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/menu.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "menu.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "menu@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "menu@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/menu.imageset/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/menu.imageset/menu.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/menu.imageset/menu@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/menu.imageset/menu@2x.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/menu.imageset/menu@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/menu.imageset/menu@3x.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/video-indicator.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "video-indicator.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "video-indicator@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "video-indicator@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/video-indicator.imageset/video-indicator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/video-indicator.imageset/video-indicator.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/video-indicator.imageset/video-indicator@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/video-indicator.imageset/video-indicator@2x.png -------------------------------------------------------------------------------- /Resources/SharedAssets.xcassets/video-indicator.imageset/video-indicator@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Resources/SharedAssets.xcassets/video-indicator.imageset/video-indicator@3x.png -------------------------------------------------------------------------------- /Source/DefaultHeaderView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol DefaultHeaderViewDelegate: class { 4 | func headerView(_ headerView: DefaultHeaderView, didPressClearButton button: UIButton) 5 | } 6 | 7 | class DefaultHeaderView: UIView { 8 | weak var delegate: DefaultHeaderViewDelegate? 9 | static let ButtonSize = CGFloat(50.0) 10 | static let TopMargin = CGFloat(14.0) 11 | 12 | lazy var clearButton: UIButton = { 13 | let image = UIImage.close 14 | let button = UIButton(type: .custom) 15 | button.setImage(image, for: .normal) 16 | button.addTarget(self, action: #selector(DefaultHeaderView.clearAction(button:)), for: .touchUpInside) 17 | 18 | return button 19 | }() 20 | 21 | override init(frame: CGRect) { 22 | super.init(frame: frame) 23 | 24 | self.addSubview(self.clearButton) 25 | } 26 | 27 | required init?(coder _: NSCoder) { 28 | fatalError("init(coder:) has not been implemented") 29 | } 30 | 31 | override func layoutSubviews() { 32 | super.layoutSubviews() 33 | 34 | self.clearButton.frame = CGRect(x: 4, y: DefaultHeaderView.TopMargin, width: DefaultHeaderView.ButtonSize, height: DefaultHeaderView.ButtonSize) 35 | } 36 | 37 | @objc func clearAction(button: UIButton) { 38 | self.delegate?.headerView(self, didPressClearButton: button) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Source/NSIndexPath+Contiguous.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension IndexPath { 4 | 5 | enum Direction { 6 | case forward 7 | case backward 8 | case same 9 | } 10 | 11 | func indexPaths(_ collectionView: UICollectionView) -> [IndexPath] { 12 | var indexPaths = [IndexPath]() 13 | 14 | let sections = collectionView.numberOfSections 15 | for section in 0 ..< sections { 16 | let rows = collectionView.numberOfItems(inSection: section) 17 | for row in 0 ..< rows { 18 | indexPaths.append(IndexPath(row: row, section: section)) 19 | } 20 | } 21 | 22 | return indexPaths 23 | } 24 | 25 | func next(_ collectionView: UICollectionView) -> IndexPath? { 26 | var found = false 27 | let indexPaths = self.indexPaths(collectionView) 28 | for indexPath in indexPaths { 29 | if found { 30 | return indexPath 31 | } 32 | 33 | if indexPath == self { 34 | found = true 35 | } 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func previous(_ collectionView: UICollectionView) -> IndexPath? { 42 | var previousIndexPath: IndexPath? 43 | let indexPaths = self.indexPaths(collectionView) 44 | for indexPath in indexPaths { 45 | if indexPath == self { 46 | return previousIndexPath 47 | } 48 | 49 | previousIndexPath = indexPath 50 | } 51 | 52 | return nil 53 | } 54 | 55 | static func indexPathForIndex(_ collectionView: UICollectionView, index: Int) -> IndexPath? { 56 | var count = 0 57 | let sections = collectionView.numberOfSections 58 | for section in 0 ..< sections { 59 | let rows = collectionView.numberOfItems(inSection: section) 60 | if index >= count && index < count + rows { 61 | let foundRow = index - count 62 | return IndexPath(row: foundRow, section: section) 63 | } 64 | count += rows 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func totalRow(_ collectionView: UICollectionView) -> Int { 71 | var count = 0 72 | let sections = collectionView.numberOfSections 73 | for section in 0 ..< sections { 74 | if section < self.section { 75 | let rows = collectionView.numberOfItems(inSection: section) 76 | count += rows 77 | } 78 | } 79 | 80 | return count + self.row 81 | } 82 | 83 | func compareDirection(_ indexPath: IndexPath) -> Direction { 84 | let current = self.row * self.section 85 | let coming = indexPath.row * indexPath.section 86 | 87 | if current == coming { 88 | return .same 89 | } else if current < coming { 90 | return .forward 91 | } else { 92 | return .backward 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Source/PaginatedScrollView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class PaginatedScrollView: UIScrollView, ViewableControllerContainer { 4 | weak var viewDataSource: ViewableControllerContainerDataSource? 5 | weak var viewDelegate: ViewableControllerContainerDelegate? 6 | fileprivate unowned var parentController: UIViewController 7 | fileprivate var currentPage: Int 8 | fileprivate var shoudEvaluate = false 9 | 10 | init(frame: CGRect, parentController: UIViewController, initialPage: Int) { 11 | self.parentController = parentController 12 | self.currentPage = initialPage 13 | 14 | super.init(frame: frame) 15 | 16 | #if os(iOS) 17 | self.isPagingEnabled = true 18 | self.scrollsToTop = false 19 | #endif 20 | self.showsHorizontalScrollIndicator = false 21 | self.showsVerticalScrollIndicator = false 22 | self.delegate = self 23 | self.autoresizingMask = [.flexibleWidth, .flexibleHeight] 24 | 25 | #if os(iOS) 26 | self.decelerationRate = UIScrollView.DecelerationRate.fast 27 | #endif 28 | } 29 | 30 | required init?(coder _: NSCoder) { 31 | fatalError("init(coder:) has not been implemented") 32 | } 33 | 34 | func configure() { 35 | self.subviews.forEach { view in 36 | view.removeFromSuperview() 37 | } 38 | 39 | let numPages = self.viewDataSource?.numberOfPagesInViewableControllerContainer(self) ?? 0 40 | self.contentSize = CGSize(width: self.frame.size.width * CGFloat(numPages), height: self.frame.size.height) 41 | 42 | self.loadScrollViewWithPage(self.currentPage - 1) 43 | self.loadScrollViewWithPage(self.currentPage) 44 | self.loadScrollViewWithPage(self.currentPage + 1) 45 | self.gotoPage(self.currentPage, animated: false) 46 | } 47 | 48 | fileprivate func loadScrollViewWithPage(_ page: Int) { 49 | let numPages = self.viewDataSource?.numberOfPagesInViewableControllerContainer(self) ?? 0 50 | if page >= numPages || page < 0 { 51 | return 52 | } 53 | 54 | if let controller = self.viewDataSource?.viewableControllerContainer(self, controllerAtIndex: page), controller.view.superview == nil { 55 | var frame = self.frame 56 | frame.origin.x = frame.size.width * CGFloat(page) 57 | frame.origin.y = 0 58 | controller.view.frame = frame 59 | 60 | self.parentController.addChild(controller) 61 | self.addSubview(controller.view) 62 | controller.didMove(toParent: self.parentController) 63 | } 64 | } 65 | 66 | fileprivate func gotoPage(_ page: Int, animated: Bool) { 67 | self.loadScrollViewWithPage(page - 1) 68 | self.loadScrollViewWithPage(page) 69 | self.loadScrollViewWithPage(page + 1) 70 | 71 | var bounds = self.bounds 72 | bounds.origin.x = bounds.size.width * CGFloat(page) 73 | bounds.origin.y = 0 74 | 75 | self.scrollRectToVisible(bounds, animated: animated) 76 | } 77 | 78 | func goRight() { 79 | let numPages = self.viewDataSource?.numberOfPagesInViewableControllerContainer(self) ?? 0 80 | let newPage = self.currentPage + 1 81 | guard newPage <= numPages else { return } 82 | 83 | self.gotoPage(newPage, animated: true) 84 | } 85 | 86 | func goLeft() { 87 | let newPage = self.currentPage - 1 88 | guard newPage >= 0 else { return } 89 | 90 | self.gotoPage(newPage, animated: true) 91 | } 92 | } 93 | 94 | extension PaginatedScrollView: UIScrollViewDelegate { 95 | 96 | func scrollViewWillBeginDragging(_: UIScrollView) { 97 | self.shoudEvaluate = true 98 | } 99 | 100 | func scrollViewDidEndDecelerating(_: UIScrollView) { 101 | self.shoudEvaluate = false 102 | } 103 | 104 | func scrollViewDidScroll(_: UIScrollView) { 105 | if self.shoudEvaluate { 106 | let pageWidth = self.frame.size.width 107 | let page = Int(floor((self.contentOffset.x - pageWidth / 2) / pageWidth) + 1) 108 | if page != self.currentPage { 109 | self.viewDelegate?.viewableControllerContainer(self, didMoveToIndex: page) 110 | self.viewDelegate?.viewableControllerContainer(self, didMoveFromIndex: self.currentPage) 111 | } 112 | self.currentPage = page 113 | 114 | self.loadScrollViewWithPage(page - 1) 115 | self.loadScrollViewWithPage(page) 116 | self.loadScrollViewWithPage(page + 1) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Source/SlideshowView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | 4 | /// The current implementation of SlideshowVideo will ignore videos, if a video is the initially presented element then 5 | /// it will instantly jump to the next element. If the next element is a video it will continue jumping until a photo 6 | /// is found. 7 | class SlideshowView: UIView, ViewableControllerContainer { 8 | weak var dataSource: ViewableControllerContainerDataSource? 9 | weak var delegate: ViewableControllerContainerDelegate? 10 | 11 | fileprivate static let fadeDuration: Double = 1 12 | fileprivate static let transitionToNextDuration: Double = 6 13 | fileprivate unowned var parentController: UIViewController 14 | fileprivate var currentPage: Int 15 | fileprivate var currentController: ViewableController? 16 | 17 | fileprivate lazy var timer: Timer = { 18 | let timer = Timer(timeInterval: SlideshowView.transitionToNextDuration, target: self, selector: #selector(loadNext), userInfo: nil, repeats: true) 19 | 20 | return timer 21 | }() 22 | 23 | init(frame: CGRect, parentController: UIViewController, initialPage: Int) { 24 | self.parentController = parentController 25 | self.currentPage = initialPage 26 | 27 | super.init(frame: frame) 28 | 29 | self.autoresizingMask = [.flexibleWidth, .flexibleHeight] 30 | } 31 | 32 | required init?(coder _: NSCoder) { 33 | fatalError("init(coder:) has not been implemented") 34 | } 35 | 36 | func configure() { 37 | self.loadPage(self.currentPage, isInitial: true) 38 | 39 | if self.isVideo(at: self.currentPage) { 40 | self.loadNext() 41 | } 42 | } 43 | 44 | func start() { 45 | RunLoop.current.add(self.timer, forMode: RunLoop.Mode.default) 46 | } 47 | 48 | func stop() { 49 | self.timer.invalidate() 50 | } 51 | } 52 | 53 | extension SlideshowView { 54 | fileprivate func loadPage(_ page: Int, isInitial: Bool) { 55 | if page >= self.numberOfPages || page < 0 { 56 | return 57 | } 58 | 59 | guard let controller = self.dataSource?.viewableControllerContainer(self, controllerAtIndex: page) as? ViewableController else { return } 60 | guard let image = controller.viewable?.placeholder else { return } 61 | 62 | controller.view.frame = image.centeredFrame() 63 | self.parentController.addChild(controller) 64 | self.addSubview(controller.view) 65 | controller.didMove(toParent: self.parentController) 66 | 67 | if isInitial { 68 | self.currentController = controller 69 | } else { 70 | self.delegate?.viewableControllerContainer(self, didMoveFromIndex: self.currentPage) 71 | self.delegate?.viewableControllerContainer(self, didMoveToIndex: page) 72 | 73 | controller.view.alpha = 0 74 | UIView.animate(withDuration: SlideshowView.fadeDuration, delay: 0, options: [.curveEaseInOut, .beginFromCurrentState, .allowUserInteraction], animations: { 75 | self.currentController?.view.alpha = 0 76 | controller.view.alpha = 1 77 | }, completion: { isFinished in 78 | self.currentController?.willMove(toParent: nil) 79 | self.currentController?.view.removeFromSuperview() 80 | self.currentController?.removeFromParent() 81 | self.currentController = nil 82 | 83 | self.currentController = controller 84 | 85 | self.currentPage = page 86 | }) 87 | } 88 | } 89 | 90 | @objc func loadNext() { 91 | var newPage = self.currentPage + 1 92 | guard newPage <= self.numberOfPages else { return } 93 | 94 | let hasReachedEnd = newPage == self.numberOfPages 95 | if hasReachedEnd { 96 | newPage = 0 97 | } 98 | 99 | if self.isVideo(at: newPage) { 100 | self.currentPage = newPage 101 | self.loadNext() 102 | } else { 103 | self.loadPage(newPage, isInitial: false) 104 | } 105 | } 106 | 107 | 108 | fileprivate func isVideo(at index: Int) -> Bool { 109 | if let controller = self.dataSource?.viewableControllerContainer(self, controllerAtIndex: index) as? ViewableController { 110 | return controller.viewable?.type == .video 111 | } 112 | 113 | return false 114 | } 115 | 116 | fileprivate var numberOfPages: Int { 117 | return self.dataSource?.numberOfPagesInViewableControllerContainer(self) ?? 0 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Source/UIImage+CenteredFrame.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIImage { 4 | 5 | func centeredFrame() -> CGRect { 6 | let screenBounds = UIScreen.main.bounds 7 | let widthScaleFactor = self.size.width / screenBounds.size.width 8 | let heightScaleFactor = self.size.height / screenBounds.size.height 9 | var centeredFrame = CGRect.zero 10 | 11 | let shouldFitHorizontally = widthScaleFactor > heightScaleFactor 12 | if shouldFitHorizontally && widthScaleFactor > 0 { 13 | let y = (screenBounds.size.height / 2) - ((self.size.height / widthScaleFactor) / 2) 14 | centeredFrame = CGRect(x: 0, y: y, width: screenBounds.size.width, height: self.size.height / widthScaleFactor) 15 | } else if heightScaleFactor > 0 { 16 | let x = (screenBounds.size.width / 2) - ((self.size.width / heightScaleFactor) / 2) 17 | centeredFrame = CGRect(x: x, y: 0, width: screenBounds.size.width - (2 * x), height: screenBounds.size.height) 18 | } 19 | 20 | return centeredFrame 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Source/UIViewController+Window.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | 5 | func applicationWindow() -> UIWindow { 6 | return UIApplication.shared.keyWindow! 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Source/UIViewExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © FINN.no AS, Inc. All rights reserved. 3 | // 4 | 5 | import UIKit 6 | 7 | public extension UIView { 8 | convenience init(withAutoLayout autoLayout: Bool) { 9 | self.init() 10 | translatesAutoresizingMaskIntoConstraints = !autoLayout 11 | } 12 | 13 | var compatibleTopAnchor: NSLayoutYAxisAnchor { 14 | if #available(iOS 11.0, *) { 15 | return safeAreaLayoutGuide.topAnchor 16 | } else { 17 | return topAnchor 18 | } 19 | } 20 | 21 | var compatibleBottomAnchor: NSLayoutYAxisAnchor { 22 | if #available(iOS 11.0, *) { 23 | return safeAreaLayoutGuide.bottomAnchor 24 | } else { 25 | return bottomAnchor 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Source/VideoProgressView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol VideoProgressViewDelegate: class { 4 | func videoProgressViewDidBeginSeeking(_ videoProgressView: VideoProgressView) 5 | func videoProgressViewDidSeek(_ videoProgressView: VideoProgressView, toDuration duration: Double) 6 | func videoProgressViewDidEndSeeking(_ videoProgressView: VideoProgressView) 7 | } 8 | 9 | class VideoProgressView: UIView { 10 | weak var delegate: VideoProgressViewDelegate? 11 | 12 | #if os(iOS) 13 | static let height = CGFloat(55.0) 14 | private static let progressBarYMargin = CGFloat(23.0) 15 | private static let progressBarHeight = CGFloat(6.0) 16 | 17 | private static let textLabelHeight = CGFloat(18.0) 18 | private static let textLabelMargin = CGFloat(18.0) 19 | 20 | private static let seekViewHeight = CGFloat(45.0) 21 | private static let seekViewWidth = CGFloat(45.0) 22 | 23 | private static let font = UIFont.systemFont(ofSize: 14) 24 | #else 25 | static let height = CGFloat(110.0) 26 | private static let progressBarYMargin = CGFloat(46.0) 27 | private static let progressBarHeight = CGFloat(23.0) 28 | 29 | private static let textLabelHeight = CGFloat(36.0) 30 | private static let textLabelMargin = CGFloat(36.0) 31 | 32 | private static let seekViewHeight = CGFloat(90.0) 33 | private static let seekViewWidth = CGFloat(90.0) 34 | 35 | private static let font = UIFont.systemFont(ofSize: 28) 36 | #endif 37 | 38 | var duration = 0.0 { 39 | didSet { 40 | if self.duration != oldValue { 41 | self.durationTimeLabel.text = self.duration.timeString() 42 | self.layoutSubviews() 43 | } 44 | } 45 | } 46 | 47 | var progress = 0.0 { 48 | didSet { 49 | self.currentTimeLabel.text = self.progress.timeString() 50 | self.layoutSubviews() 51 | } 52 | } 53 | 54 | var progressPercentage: Double { 55 | guard self.progress != 0.0 && self.duration != 0.0 else { 56 | return 0.0 57 | } 58 | 59 | return self.progress / self.duration 60 | } 61 | 62 | lazy var progressBarMask: UIView = { 63 | let maskView = UIView() 64 | maskView.backgroundColor = .clear 65 | maskView.layer.cornerRadius = VideoProgressView.progressBarHeight / 2 66 | maskView.clipsToBounds = true 67 | maskView.layer.masksToBounds = true 68 | 69 | return maskView 70 | }() 71 | 72 | lazy var backgroundBar: UIView = { 73 | let backgroundBar = UIView() 74 | backgroundBar.backgroundColor = .white 75 | backgroundBar.alpha = 0.2 76 | 77 | return backgroundBar 78 | }() 79 | 80 | lazy var progressBar: UIView = { 81 | let progressBar = UIView() 82 | progressBar.backgroundColor = .white 83 | 84 | return progressBar 85 | }() 86 | 87 | lazy var currentTimeLabel: UILabel = { 88 | let label = UILabel() 89 | label.font = VideoProgressView.font 90 | label.textColor = .white 91 | label.textAlignment = .center 92 | 93 | return label 94 | }() 95 | 96 | lazy var durationTimeLabel: UILabel = { 97 | let label = UILabel() 98 | label.font = VideoProgressView.font 99 | label.textColor = .white 100 | label.textAlignment = .center 101 | 102 | return label 103 | }() 104 | 105 | lazy var seekView: UIImageView = { 106 | let view = UIImageView() 107 | view.isUserInteractionEnabled = true 108 | view.image = UIImage.seek 109 | view.contentMode = .scaleAspectFit 110 | 111 | return view 112 | }() 113 | 114 | override init(frame: CGRect) { 115 | super.init(frame: frame) 116 | 117 | self.addSubview(self.progressBarMask) 118 | self.progressBarMask.addSubview(self.backgroundBar) 119 | self.progressBarMask.addSubview(self.progressBar) 120 | 121 | self.addSubview(self.seekView) 122 | self.addSubview(self.currentTimeLabel) 123 | self.addSubview(self.durationTimeLabel) 124 | 125 | let panGesture = UIPanGestureRecognizer(target: self, action: #selector(seek(gestureRecognizer:))) 126 | self.seekView.addGestureRecognizer(panGesture) 127 | 128 | #if os(tvOS) 129 | self.seekView.isHidden = true 130 | #endif 131 | } 132 | 133 | required init?(coder _: NSCoder) { 134 | fatalError("init(coder:) has not been implemented") 135 | } 136 | 137 | override func layoutSubviews() { 138 | super.layoutSubviews() 139 | 140 | var currentTimeLabelFrame: CGRect { 141 | let width = self.currentTimeLabel.width() + VideoProgressView.textLabelMargin 142 | return CGRect(x: 0, y: VideoProgressView.textLabelMargin, width: width, height: VideoProgressView.textLabelHeight) 143 | } 144 | self.currentTimeLabel.frame = currentTimeLabelFrame 145 | 146 | var durationTimeLabelFrame: CGRect { 147 | let width = self.durationTimeLabel.width() + VideoProgressView.textLabelMargin 148 | let x = self.bounds.width - width 149 | return CGRect(x: x, y: VideoProgressView.textLabelMargin, width: width, height: VideoProgressView.textLabelHeight) 150 | } 151 | self.durationTimeLabel.frame = durationTimeLabelFrame 152 | 153 | var maskBarForRoundedCornersFrame: CGRect { 154 | let x = self.currentTimeLabel.frame.width 155 | let width = self.bounds.width - self.currentTimeLabel.frame.width - self.durationTimeLabel.frame.width 156 | return CGRect(x: x, y: VideoProgressView.progressBarYMargin, width: width, height: VideoProgressView.progressBarHeight) 157 | } 158 | self.progressBarMask.frame = maskBarForRoundedCornersFrame 159 | 160 | var backgroundBarFrame: CGRect { 161 | let width = self.progressBarMask.frame.width 162 | return CGRect(x: 0, y: 0, width: width, height: VideoProgressView.progressBarHeight) 163 | } 164 | self.backgroundBar.frame = backgroundBarFrame 165 | 166 | var progressBarFrame: CGRect { 167 | let width = self.progressBarMask.frame.width * CGFloat(self.progressPercentage) 168 | return CGRect(x: 0, y: 0, width: width, height: VideoProgressView.progressBarHeight) 169 | } 170 | self.progressBar.frame = progressBarFrame 171 | 172 | var seekViewFrame: CGRect { 173 | let x = self.progressBarMask.frame.origin.x + (self.progressBarMask.frame.size.width * CGFloat(self.progressPercentage)) - (VideoProgressView.seekViewWidth / 2) 174 | return CGRect(x: x, y: VideoProgressView.textLabelMargin, width: VideoProgressView.seekViewWidth, height: VideoProgressView.textLabelHeight) 175 | } 176 | self.seekView.frame = seekViewFrame 177 | } 178 | 179 | @objc func seek(gestureRecognizer: UIPanGestureRecognizer) { 180 | switch gestureRecognizer.state { 181 | case .began: 182 | self.delegate?.videoProgressViewDidBeginSeeking(self) 183 | case .changed: 184 | var pannableFrame = self.progressBarMask.frame 185 | pannableFrame.size.height = self.frame.height 186 | 187 | let translation = gestureRecognizer.translation(in: self.seekView) 188 | let newCenter = CGPoint(x: gestureRecognizer.view!.center.x + translation.x, y: gestureRecognizer.view!.center.y) 189 | let newX = newCenter.x - (VideoProgressView.seekViewWidth / 2) 190 | var progressPercentage = Double((-(self.progressBarMask.frame.origin.x - (VideoProgressView.seekViewWidth / 2) - newX)) / self.progressBarMask.frame.size.width) 191 | if progressPercentage < 0 { 192 | progressPercentage = 0 193 | } else if progressPercentage > 1 { 194 | progressPercentage = 1 195 | } 196 | 197 | if progressPercentage == 0 || progressPercentage == 1 { 198 | let x = self.progressBarMask.frame.origin.x + (self.progressBarMask.frame.size.width * CGFloat(progressPercentage)) - (VideoProgressView.seekViewWidth / 2) 199 | var frame = self.seekView.frame 200 | frame.origin.x = x 201 | self.seekView.frame = frame 202 | return 203 | } 204 | 205 | var progress = progressPercentage * self.duration 206 | if progress < 0 { 207 | progress = 0 208 | } else if progress > self.duration { 209 | progress = self.duration 210 | } 211 | 212 | self.progress = progress 213 | 214 | gestureRecognizer.view!.center = newCenter 215 | gestureRecognizer.setTranslation(CGPoint.zero, in: self.seekView) 216 | self.delegate?.videoProgressViewDidSeek(self, toDuration: progress) 217 | case .ended: 218 | self.delegate?.videoProgressViewDidEndSeeking(self) 219 | default: 220 | break 221 | } 222 | } 223 | } 224 | 225 | public extension UILabel { 226 | 227 | func width() -> CGFloat { 228 | let rect = (self.attributedText ?? NSAttributedString()).boundingRect(with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), options: .usesLineFragmentOrigin, context: nil) 229 | return rect.width 230 | } 231 | } 232 | 233 | extension Double { 234 | 235 | func timeString() -> String { 236 | let remaining = floor(self) 237 | let hours = Int(remaining / 3600) 238 | let minutes = Int(remaining / 60) - hours * 60 239 | let seconds = Int(remaining) - hours * 3600 - minutes * 60 240 | 241 | let formatter = NumberFormatter() 242 | formatter.minimumIntegerDigits = 2 243 | 244 | let secondsString = String(format: "%02d", seconds) 245 | 246 | if hours > 0 { 247 | let hoursString = formatter.string(from: NSNumber(value: hours)) 248 | if let hoursString = hoursString { 249 | let minutesString = String(format: "%02d", minutes) 250 | return "\(hoursString):\(minutesString):\(secondsString)" 251 | } 252 | } else { 253 | if let minutesString = formatter.string(from: NSNumber(value: minutes)) { 254 | return "\(minutesString):\(secondsString)" 255 | } 256 | } 257 | 258 | return "" 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /Source/VideoView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import AVFoundation 3 | import AVKit 4 | 5 | #if os(iOS) 6 | import Photos 7 | #endif 8 | 9 | protocol VideoViewDelegate: class { 10 | func videoViewDidStartPlaying(_ videoView: VideoView) 11 | func videoView(_ videoView: VideoView, didChangeProgress progress: Double, duration: Double) 12 | func videoViewDidFinishPlaying(_ videoView: VideoView, error: NSError?) 13 | } 14 | 15 | class VideoView: UIView { 16 | static let playerItemStatusKeyPath = "status" 17 | static let audioSessionVolumeKeyPath = "outputVolume" 18 | weak var delegate: VideoViewDelegate? 19 | var playerCurrentItemStatus = AVPlayerItem.Status.unknown 20 | 21 | fileprivate lazy var playerLayer: AVPlayerLayer = { 22 | let playerLayer = AVPlayerLayer() 23 | 24 | playerLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill 25 | 26 | return playerLayer 27 | }() 28 | 29 | var image: UIImage? 30 | 31 | private lazy var loadingIndicator: UIActivityIndicatorView = { 32 | let view = UIActivityIndicatorView(style: .whiteLarge) 33 | view.autoresizingMask = [.flexibleLeftMargin, .flexibleTopMargin] 34 | 35 | return view 36 | }() 37 | 38 | private lazy var loadingIndicatorBackground: UIImageView = { 39 | let view = UIImageView(image: .darkCircle) 40 | view.alpha = 0 41 | 42 | return view 43 | }() 44 | 45 | fileprivate var shouldRegisterForStatusNotifications = true 46 | fileprivate var shouldRegisterForFailureOrEndingNotifications = true 47 | fileprivate var shouldRegisterForOutputVolume = true 48 | 49 | fileprivate var playbackProgressTimeObserver: Any? 50 | 51 | override init(frame: CGRect) { 52 | super.init(frame: frame) 53 | 54 | self.autoresizingMask = [.flexibleLeftMargin, .flexibleTopMargin, .flexibleWidth, .flexibleHeight] 55 | self.isUserInteractionEnabled = false 56 | self.layer.addSublayer(self.playerLayer) 57 | self.addSubview(self.loadingIndicatorBackground) 58 | self.addSubview(self.loadingIndicator) 59 | } 60 | 61 | required init?(coder _: NSCoder) { 62 | fatalError("init(coder:) has not been implemented") 63 | } 64 | 65 | // Proposed workaround to fix some issues with observers called after being deallocated. 66 | // Error description: 67 | // Fatal Exception: NSInternalInconsistencyException 68 | // An instance 0x15ed87220 of class AVPlayerItem was deallocated while key value observers were still registered with it. 69 | deinit { 70 | self.removeBeforePlayingObservers() 71 | self.removeWhilePlayingObservers() 72 | } 73 | 74 | override func layoutSubviews() { 75 | super.layoutSubviews() 76 | 77 | guard let image = self.image else { return } 78 | self.frame = image.centeredFrame() 79 | 80 | var playerLayerFrame = image.centeredFrame() 81 | playerLayerFrame.origin.x = 0 82 | playerLayerFrame.origin.y = 0 83 | self.playerLayer.frame = playerLayerFrame 84 | 85 | let loadingBackgroundHeight = self.loadingIndicatorBackground.frame.size.height 86 | let loadingBackgroundWidth = self.loadingIndicatorBackground.frame.size.width 87 | self.loadingIndicatorBackground.frame = CGRect(x: (self.frame.size.width - loadingBackgroundWidth) / 2, y: (self.frame.size.height - loadingBackgroundHeight) / 2, width: loadingBackgroundWidth, height: loadingBackgroundHeight) 88 | 89 | let loadingHeight = self.loadingIndicator.frame.size.height 90 | let loadingWidth = self.loadingIndicator.frame.size.width 91 | self.loadingIndicator.frame = CGRect(x: (self.frame.size.width - loadingWidth) / 2, y: (self.frame.size.height - loadingHeight) / 2, width: loadingWidth, height: loadingHeight) 92 | } 93 | 94 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change _: [NSKeyValueChangeKey: Any]?, context _: UnsafeMutableRawPointer?) { 95 | 96 | if keyPath == VideoView.audioSessionVolumeKeyPath { 97 | #if os(iOS) 98 | try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) 99 | #endif 100 | return 101 | } 102 | 103 | guard let playerItem = object as? AVPlayerItem else { return } 104 | self.playerCurrentItemStatus = playerItem.status 105 | 106 | if let error = playerItem.error { 107 | self.handleError(error as NSError) 108 | } else { 109 | guard let player = self.playerLayer.player else { 110 | let error = NSError(domain: ViewerController.domain, code: 0, userInfo: [NSLocalizedDescriptionKey: "Player not found."]) 111 | self.handleError(error) 112 | return 113 | } 114 | guard keyPath == VideoView.playerItemStatusKeyPath else { return } 115 | guard playerItem.status == .readyToPlay else { return } 116 | 117 | self.playerLayer.player?.pause() 118 | self.removeBeforePlayingObservers() 119 | 120 | if self.playerLayer.isHidden == false { 121 | player.play() 122 | self.delegate?.videoViewDidStartPlaying(self) 123 | } 124 | 125 | if let playbackProgressTimeObserver = self.playbackProgressTimeObserver { 126 | player.removeTimeObserver(playbackProgressTimeObserver) 127 | self.playbackProgressTimeObserver = nil 128 | } 129 | 130 | let interval = CMTime(seconds: 1 / 60, preferredTimescale: Int32(NSEC_PER_SEC)) 131 | self.playbackProgressTimeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: nil) { _ in 132 | self.loadingIndicator.stopAnimating() 133 | self.loadingIndicatorBackground.alpha = 0 134 | 135 | let duration = CMTimeGetSeconds(playerItem.asset.duration) 136 | let currentTime = CMTimeGetSeconds(player.currentTime()) 137 | // In some cases the video will start playing with negative current time. 138 | if currentTime > 0 { 139 | self.delegate?.videoView(self, didChangeProgress: currentTime, duration: duration) 140 | } 141 | } 142 | } 143 | } 144 | 145 | func handleError(_ error: NSError) { 146 | self.playerLayer.player?.pause() 147 | self.removeBeforePlayingObservers() 148 | self.removeWhilePlayingObservers() 149 | self.delegate?.videoViewDidFinishPlaying(self, error: error as NSError?) 150 | } 151 | 152 | func prepare(using viewable: Viewable, completion: @escaping () -> Void) { 153 | self.addPlayer(using: viewable) { 154 | if self.shouldRegisterForStatusNotifications { 155 | guard let player = self.playerLayer.player else { return } 156 | guard let currentItem = player.currentItem else { return } 157 | 158 | self.shouldRegisterForStatusNotifications = false 159 | currentItem.addObserver(self, forKeyPath: VideoView.playerItemStatusKeyPath, options: [], context: nil) 160 | 161 | do { 162 | let audioSession = AVAudioSession.sharedInstance() 163 | try audioSession.setActive(true) 164 | audioSession.addObserver(self, forKeyPath: VideoView.audioSessionVolumeKeyPath, options: .new, context: nil) 165 | self.shouldRegisterForOutputVolume = false 166 | } catch { 167 | print("Failed to activate audio session") 168 | } 169 | } 170 | 171 | if self.shouldRegisterForFailureOrEndingNotifications { 172 | self.shouldRegisterForFailureOrEndingNotifications = false 173 | NotificationCenter.default.addObserver(self, selector: #selector(self.videoFinishedPlaying), name: .AVPlayerItemDidPlayToEndTime, object: nil) 174 | NotificationCenter.default.addObserver(self, selector: #selector(self.itemPlaybackStalled), name: .AVPlayerItemPlaybackStalled, object: nil) 175 | } 176 | 177 | completion() 178 | } 179 | } 180 | 181 | func `repeat`() { 182 | self.playerLayer.player?.seek(to: CMTime.zero) 183 | self.playerLayer.player?.play() 184 | } 185 | 186 | func stop() { 187 | self.removeBeforePlayingObservers() 188 | self.removeWhilePlayingObservers() 189 | 190 | self.playerLayer.isHidden = true 191 | self.playerLayer.player?.pause() 192 | self.playerLayer.player?.seek(to: CMTime.zero) 193 | self.playerLayer.player = nil 194 | 195 | #if os(iOS) 196 | try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default, options: []) 197 | #endif 198 | } 199 | 200 | func play() { 201 | guard let player = self.playerLayer.player else { 202 | let error = NSError(domain: ViewerController.domain, code: 0, userInfo: [NSLocalizedDescriptionKey: "No player was found."]) 203 | self.handleError(error) 204 | return 205 | } 206 | 207 | if player.status == .unknown { 208 | self.loadingIndicator.startAnimating() 209 | self.loadingIndicatorBackground.alpha = 1 210 | } 211 | 212 | self.playerLayer.player?.play() 213 | self.playerLayer.isHidden = false 214 | } 215 | 216 | func pause() { 217 | self.playerLayer.player?.pause() 218 | } 219 | 220 | func isPlaying() -> Bool { 221 | if let player = self.playerLayer.player { 222 | let isPlaying = player.rate != 0 && player.error == nil 223 | return isPlaying 224 | } 225 | 226 | return false 227 | } 228 | 229 | // Source: 230 | // Technical Q&A QA1820 231 | // How do I achieve smooth video scrubbing with AVPlayer seekToTime:? 232 | // https://developer.apple.com/library/content/qa/qa1820/_index.html 233 | var isSeekInProgress = false 234 | var chaseTime = CMTime.zero 235 | 236 | func stopPlayingAndSeekSmoothlyToTime(duration: Double) { 237 | guard let timescale = self.playerLayer.player?.currentItem?.currentTime().timescale else { return } 238 | let newChaseTime = CMTime(seconds: duration, preferredTimescale: timescale) 239 | self.playerLayer.player?.pause() 240 | 241 | if CMTimeCompare(newChaseTime, self.chaseTime) != 0 { 242 | self.chaseTime = newChaseTime 243 | 244 | if self.isSeekInProgress == false { 245 | self.trySeekToChaseTime() 246 | } 247 | } 248 | } 249 | 250 | func trySeekToChaseTime() { 251 | switch self.playerCurrentItemStatus { 252 | case .unknown: 253 | // wait until item becomes ready (KVO player.currentItem.status) 254 | break 255 | case .readyToPlay: 256 | self.actuallySeekToTime() 257 | case .failed: 258 | break 259 | @unknown default: 260 | break 261 | } 262 | } 263 | 264 | func actuallySeekToTime() { 265 | self.isSeekInProgress = true 266 | let seekTimeInProgress = self.chaseTime 267 | self.playerLayer.player?.seek(to: seekTimeInProgress, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) { _ in 268 | if CMTimeCompare(seekTimeInProgress, self.chaseTime) == 0 { 269 | self.isSeekInProgress = false 270 | } else { 271 | self.trySeekToChaseTime() 272 | } 273 | } 274 | } 275 | } 276 | 277 | extension VideoView { 278 | 279 | fileprivate func addPlayer(using viewable: Viewable, completion: @escaping () -> Void) { 280 | if let assetID = viewable.assetID { 281 | #if os(iOS) 282 | let result = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil) 283 | guard let asset = result.firstObject else { 284 | let error = NSError(domain: ViewerController.domain, code: 0, userInfo: [NSLocalizedDescriptionKey: "Couldn't get asset for id: \(assetID)."]) 285 | self.handleError(error) 286 | return 287 | } 288 | let requestOptions = PHVideoRequestOptions() 289 | requestOptions.isNetworkAccessAllowed = true 290 | PHImageManager.default().requestPlayerItem(forVideo: asset, options: requestOptions) { playerItem, info in 291 | guard let playerItem = playerItem else { 292 | let error = NSError(domain: ViewerController.domain, code: 0, userInfo: [NSLocalizedDescriptionKey: "Couldn't create player: \(String(describing: info))."]) 293 | self.handleError(error) 294 | return 295 | } 296 | 297 | if let player = self.playerLayer.player { 298 | player.replaceCurrentItem(with: playerItem) 299 | } else { 300 | let player = AVPlayer(playerItem: playerItem) 301 | player.rate = Float(playerItem.preferredPeakBitRate) 302 | self.playerLayer.player = player 303 | self.playerLayer.isHidden = true 304 | } 305 | 306 | DispatchQueue.main.async { 307 | completion() 308 | } 309 | } 310 | #endif 311 | } else if let url = viewable.url { 312 | DispatchQueue.global(qos: .background).async { 313 | let streamingURL = URL(string: url)! 314 | self.playerLayer.player = AVPlayer(url: streamingURL) 315 | self.playerLayer.isHidden = true 316 | 317 | DispatchQueue.main.async { 318 | completion() 319 | } 320 | } 321 | } 322 | } 323 | 324 | fileprivate func removeBeforePlayingObservers() { 325 | if self.shouldRegisterForStatusNotifications == false { 326 | guard let player = self.playerLayer.player else { return } 327 | guard let currentItem = player.currentItem else { return } 328 | 329 | self.shouldRegisterForStatusNotifications = true 330 | currentItem.removeObserver(self, forKeyPath: VideoView.playerItemStatusKeyPath) 331 | } 332 | } 333 | 334 | fileprivate func removeWhilePlayingObservers() { 335 | if let playbackProgressTimeObserver = self.playbackProgressTimeObserver { 336 | self.playerLayer.player?.removeTimeObserver(playbackProgressTimeObserver) 337 | self.playbackProgressTimeObserver = nil 338 | } 339 | 340 | if self.shouldRegisterForFailureOrEndingNotifications == false { 341 | self.shouldRegisterForFailureOrEndingNotifications = true 342 | 343 | NotificationCenter.default.removeObserver(self, name: .AVPlayerItemPlaybackStalled, object: nil) 344 | NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) 345 | } 346 | 347 | if self.shouldRegisterForOutputVolume == false { 348 | self.shouldRegisterForOutputVolume = true 349 | AVAudioSession.sharedInstance().removeObserver(self, forKeyPath: VideoView.audioSessionVolumeKeyPath) 350 | } 351 | } 352 | 353 | // When the video is having troubles buffering it might trigger the "AVPlayerItemPlaybackStalled" notification 354 | // the ideal scenario here, is that we'll pause the video, display the loading indicator for a while, 355 | // then continue the play back. 356 | // The current workaround just pauses the video and tries to play again, this might cause a shuddering video playback, 357 | // is not perfect but does the job for now. 358 | @objc fileprivate func itemPlaybackStalled() { 359 | if let player = self.playerLayer.player { 360 | player.pause() 361 | player.play() 362 | } 363 | } 364 | 365 | @objc fileprivate func videoFinishedPlaying() { 366 | self.delegate?.videoViewDidFinishPlaying(self, error: nil) 367 | } 368 | } 369 | 370 | extension CMTime { 371 | public init(seconds: Double, preferredTimescale: CMTimeScale) { 372 | self = CMTimeMakeWithSeconds(seconds, preferredTimescale: preferredTimescale) 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /Source/Viewable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public enum ViewableType: String { 4 | case image 5 | case video 6 | } 7 | 8 | public protocol Viewable { 9 | var type: ViewableType { get } 10 | var assetID: String? { get } 11 | var url: String? { get } 12 | var placeholder: UIImage { get } 13 | 14 | func media(_ completion: @escaping (_ image: UIImage?, _ error: NSError?) -> Void) 15 | } 16 | -------------------------------------------------------------------------------- /Source/ViewableController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import AVFoundation 3 | import AVKit 4 | 5 | #if os(iOS) 6 | import Photos 7 | #endif 8 | 9 | protocol ViewableControllerDelegate: class { 10 | func viewableControllerDidTapItem(_ viewableController: ViewableController) 11 | func viewableController(_ viewableController: ViewableController, didFailDisplayingVieweableWith error: NSError) 12 | } 13 | 14 | protocol ViewableControllerDataSource: class { 15 | func viewableControllerOverlayIsVisible(_ viewableController: ViewableController) -> Bool 16 | func viewableControllerIsFocused(_ viewableController: ViewableController) -> Bool 17 | func viewableControllerShouldAutoplayVideo(_ viewableController: ViewableController) -> Bool 18 | } 19 | 20 | class ViewableController: UIViewController { 21 | static let playerItemStatusKeyPath = "status" 22 | private static let FooterViewHeight = CGFloat(50.0) 23 | 24 | weak var delegate: ViewableControllerDelegate? 25 | weak var dataSource: ViewableControllerDataSource? 26 | 27 | lazy var zoomingScrollView: UIScrollView = { 28 | let scrollView = UIScrollView(frame: self.view.bounds) 29 | scrollView.delegate = self 30 | scrollView.backgroundColor = .clear 31 | scrollView.alwaysBounceVertical = false 32 | scrollView.alwaysBounceHorizontal = false 33 | scrollView.showsVerticalScrollIndicator = true 34 | scrollView.flashScrollIndicators() 35 | scrollView.minimumZoomScale = 1.0 36 | scrollView.maximumZoomScale = self.maxZoomScale() 37 | scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 38 | 39 | if #available(iOS 11.0, tvOS 11.0, *) { 40 | scrollView.contentInsetAdjustmentBehavior = .never 41 | } 42 | 43 | return scrollView 44 | }() 45 | 46 | lazy var imageView: UIImageView = { 47 | let view = UIImageView(frame: UIScreen.main.bounds) 48 | view.backgroundColor = .clear 49 | view.contentMode = .scaleAspectFit 50 | view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 51 | view.isUserInteractionEnabled = true 52 | 53 | return view 54 | }() 55 | 56 | lazy var imageLoadingIndicator: UIActivityIndicatorView = { 57 | let activityView = UIActivityIndicatorView(style: .whiteLarge) 58 | activityView.center = self.view.center 59 | activityView.hidesWhenStopped = true 60 | 61 | return activityView 62 | }() 63 | 64 | lazy var videoView: VideoView = { 65 | let view = VideoView() 66 | view.delegate = self 67 | 68 | return view 69 | }() 70 | 71 | lazy var playButton: UIButton = { 72 | let button = UIButton(type: .custom) 73 | let image = UIImage.play 74 | button.setImage(image, for: UIControl.State()) 75 | button.alpha = 0 76 | 77 | #if os(tvOS) 78 | // Disable user interaction on play button to allow drag to dismiss video thumb on tvOS 79 | button.isUserInteractionEnabled = false 80 | #else 81 | button.addTarget(self, action: #selector(ViewableController.playAction), for: .touchUpInside) 82 | #endif 83 | 84 | return button 85 | }() 86 | 87 | lazy var repeatButton: UIButton = { 88 | let button = UIButton(type: .custom) 89 | let image = UIImage.repeat 90 | button.setImage(image, for: UIControl.State()) 91 | button.alpha = 0 92 | button.addTarget(self, action: #selector(ViewableController.repeatAction), for: .touchUpInside) 93 | 94 | return button 95 | }() 96 | 97 | lazy var pauseButton: UIButton = { 98 | let button = UIButton(type: .custom) 99 | let image = UIImage.pause 100 | button.setImage(image, for: UIControl.State()) 101 | button.alpha = 0 102 | button.addTarget(self, action: #selector(ViewableController.pauseAction), for: .touchUpInside) 103 | 104 | return button 105 | }() 106 | 107 | lazy var videoProgressView: VideoProgressView = { 108 | let progressView = VideoProgressView(frame: .zero) 109 | progressView.alpha = 0 110 | progressView.delegate = self 111 | 112 | return progressView 113 | }() 114 | 115 | var changed = false 116 | var viewable: Viewable? 117 | var indexPath: IndexPath? 118 | 119 | var viewableBackgroundColor: UIColor = .black 120 | 121 | var playerViewController: AVPlayerViewController? 122 | 123 | var hasZoomed: Bool { 124 | return self.zoomingScrollView.zoomScale != 1.0 125 | } 126 | 127 | init() { 128 | super.init(nibName: nil, bundle: nil) 129 | 130 | NotificationCenter.default.addObserver(self, selector: #selector(self.videoFinishedPlaying), name: .AVPlayerItemDidPlayToEndTime, object: nil) 131 | } 132 | 133 | required init?(coder aDecoder: NSCoder) { 134 | fatalError("init(coder:) has not been implemented") 135 | } 136 | 137 | deinit { 138 | NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) 139 | self.playerViewController?.player?.currentItem?.removeObserver(self, forKeyPath: ViewableController.playerItemStatusKeyPath, context: nil) 140 | self.playerViewController = nil 141 | } 142 | 143 | func update(with viewable: Viewable, at indexPath: IndexPath) { 144 | if self.indexPath?.description != indexPath.description { 145 | self.changed = true 146 | } 147 | 148 | if self.changed { 149 | self.indexPath = indexPath 150 | self.viewable = viewable 151 | self.videoView.image = viewable.placeholder 152 | self.imageView.image = viewable.placeholder 153 | self.videoView.frame = viewable.placeholder.centeredFrame() 154 | self.changed = false 155 | } 156 | } 157 | 158 | func maxZoomScale() -> CGFloat { 159 | guard let image = self.imageView.image else { return 1 } 160 | 161 | var widthFactor = CGFloat(1.0) 162 | var heightFactor = CGFloat(1.0) 163 | if image.size.width > self.view.bounds.width { 164 | widthFactor = image.size.width / self.view.bounds.width 165 | } 166 | if image.size.height > self.view.bounds.height { 167 | heightFactor = image.size.height / self.view.bounds.height 168 | } 169 | 170 | return max(3.0, max(widthFactor, heightFactor)) 171 | } 172 | 173 | override func viewDidLoad() { 174 | super.viewDidLoad() 175 | 176 | self.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 177 | self.view.backgroundColor = self.viewableBackgroundColor 178 | 179 | self.zoomingScrollView.addSubview(self.imageView) 180 | self.view.addSubview(self.zoomingScrollView) 181 | self.view.addSubview(imageLoadingIndicator) 182 | 183 | self.view.addSubview(self.videoView) 184 | 185 | self.view.addSubview(self.playButton) 186 | self.view.addSubview(self.repeatButton) 187 | self.view.addSubview(self.pauseButton) 188 | self.view.addSubview(self.videoProgressView) 189 | 190 | let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(ViewableController.tapAction)) 191 | tapRecognizer.numberOfTapsRequired = 1 192 | self.view.addGestureRecognizer(tapRecognizer) 193 | 194 | if viewable?.type == .image { 195 | let doubleTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(ViewableController.doubleTapAction)) 196 | doubleTapRecognizer.numberOfTapsRequired = 2 197 | self.zoomingScrollView.addGestureRecognizer(doubleTapRecognizer) 198 | 199 | tapRecognizer.require(toFail: doubleTapRecognizer) 200 | } 201 | } 202 | 203 | // In iOS 10 going into landscape provides a very strange animation. Basically you'll see the other 204 | // viewer items animating on top of the focused one. Horrible. This is a workaround that hides the 205 | // non-visible viewer items to avoid that. Also we hide the placeholder image view (zoomingScrollView) 206 | // because it was animating at a different timing than the video view and it looks bad. 207 | override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 208 | super.viewWillTransition(to: size, with: coordinator) 209 | 210 | guard let viewable = self.viewable else { return } 211 | 212 | let isFocused = self.dataSource?.viewableControllerIsFocused(self) 213 | if viewable.type == .video || isFocused == false { 214 | self.view.backgroundColor = .clear 215 | self.zoomingScrollView.isHidden = true 216 | } 217 | coordinator.animate(alongsideTransition: { _ in 218 | 219 | }) { _ in 220 | if viewable.type == .video || isFocused == false { 221 | self.view.backgroundColor = .black 222 | self.zoomingScrollView.isHidden = false 223 | } 224 | 225 | self.configure() 226 | } 227 | } 228 | 229 | @objc func tapAction() { 230 | if self.videoView.isPlaying() { 231 | UIView.animate(withDuration: 0.3, animations: { 232 | self.pauseButton.alpha = self.pauseButton.alpha == 0 ? 1 : 0 233 | self.videoProgressView.alpha = self.videoProgressView.alpha == 0 ? 1 : 0 234 | }) 235 | } 236 | 237 | self.delegate?.viewableControllerDidTapItem(self) 238 | } 239 | 240 | @objc func doubleTapAction(recognizer: UITapGestureRecognizer) { 241 | let zoomScale = self.zoomingScrollView.zoomScale == 1 ? self.maxZoomScale() : 1 242 | 243 | let touchPoint = recognizer.location(in: self.imageView) 244 | 245 | let scrollViewSize = self.imageView.bounds.size 246 | 247 | let width = scrollViewSize.width / zoomScale 248 | let height = scrollViewSize.height / zoomScale 249 | let originX = touchPoint.x - (width / 2.0) 250 | let originY = touchPoint.y - (height / 2.0) 251 | 252 | let rectToZoomTo = CGRect(x: originX, y: originY, width: width, height: height) 253 | 254 | self.zoomingScrollView.zoom(to: rectToZoomTo, animated: true) 255 | } 256 | 257 | func play() { 258 | if !self.videoView.isPlaying() { 259 | self.playAction() 260 | } 261 | } 262 | 263 | override func viewWillLayoutSubviews() { 264 | super.viewWillLayoutSubviews() 265 | 266 | let buttonImage = UIImage.play 267 | let buttonHeight = buttonImage.size.height 268 | let buttonWidth = buttonImage.size.width 269 | self.playButton.frame = CGRect(x: (self.view.frame.size.width - buttonWidth) / 2, y: (self.view.frame.size.height - buttonHeight) / 2, width: buttonHeight, height: buttonHeight) 270 | self.repeatButton.frame = CGRect(x: (self.view.frame.size.width - buttonWidth) / 2, y: (self.view.frame.size.height - buttonHeight) / 2, width: buttonHeight, height: buttonHeight) 271 | self.pauseButton.frame = CGRect(x: (self.view.frame.size.width - buttonWidth) / 2, y: (self.view.frame.size.height - buttonHeight) / 2, width: buttonHeight, height: buttonHeight) 272 | 273 | self.videoProgressView.frame = CGRect(x: 0, y: (self.view.frame.height - ViewableController.FooterViewHeight - VideoProgressView.height), width: self.view.frame.width, height: VideoProgressView.height) 274 | } 275 | 276 | func willDismiss() { 277 | guard let viewable = self.viewable else { return } 278 | 279 | if viewable.type == .video { 280 | self.videoView.stop() 281 | self.resetButtonStates() 282 | } 283 | 284 | UIView.animate(withDuration: 0.3) { 285 | self.zoomingScrollView.zoomScale = 1 286 | } 287 | } 288 | 289 | func display() { 290 | guard let viewable = self.viewable else { return } 291 | 292 | switch viewable.type { 293 | case .image: 294 | // Needed to avoid showing the loading indicator for a fraction of a second. Thanks to this the 295 | // loading indicator will only be displayed when the image is taking a lot of time to load. 296 | let deadline = DispatchTime.now() + Double(Int64(0.5 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) 297 | DispatchQueue.main.asyncAfter(deadline: deadline) { 298 | if self.imageView.image == nil { 299 | self.imageLoadingIndicator.startAnimating() 300 | } 301 | } 302 | 303 | viewable.media { image, _ in 304 | DispatchQueue.main.async { 305 | self.imageLoadingIndicator.stopAnimating() 306 | if let image = image { 307 | self.imageView.image = image 308 | self.configure() 309 | } 310 | } 311 | } 312 | case .video: 313 | #if os(iOS) 314 | let shouldAutoplayVideo = self.dataSource?.viewableControllerShouldAutoplayVideo(self) ?? false 315 | if !shouldAutoplayVideo { 316 | viewable.media { image, _ in 317 | DispatchQueue.main.async { 318 | if let image = image { 319 | self.imageView.image = image 320 | } 321 | } 322 | } 323 | } 324 | 325 | self.videoView.prepare(using: viewable) { 326 | if shouldAutoplayVideo { 327 | self.videoView.play() 328 | } else { 329 | self.playButton.alpha = 1 330 | } 331 | } 332 | #else 333 | // If there's currently a `AVPlayerViewController` we want to reuse it and create a new `AVPlayer`. 334 | // One of the reasons to do this is because we found a failure in our playback because it was an expired 335 | // link and we renewed the link and want the video to play again. 336 | if let playerViewController = self.playerViewController { 337 | playerViewController.player?.currentItem?.removeObserver(self, forKeyPath: ViewableController.playerItemStatusKeyPath, context: nil) 338 | 339 | if let urlString = self.viewable?.url, let url = URL(string: urlString) { 340 | let playerItem = AVPlayerItem(url: url) 341 | playerViewController.player?.replaceCurrentItem(with: playerItem) 342 | 343 | guard let currentItem = playerViewController.player?.currentItem else { return } 344 | currentItem.addObserver(self, forKeyPath: ViewableController.playerItemStatusKeyPath, options: [], context: nil) 345 | } 346 | } else { 347 | viewable.media { image, _ in 348 | DispatchQueue.main.async { 349 | if let image = image { 350 | self.imageView.image = image 351 | self.playButton.alpha = 1 352 | } 353 | } 354 | } 355 | } 356 | #endif 357 | } 358 | } 359 | 360 | func resetButtonStates() { 361 | self.repeatButton.alpha = 0 362 | self.pauseButton.alpha = 0 363 | self.videoProgressView.alpha = 0 364 | 365 | let shouldAutoplayVideo = self.dataSource?.viewableControllerShouldAutoplayVideo(self) ?? false 366 | if !shouldAutoplayVideo { 367 | self.playButton.alpha = 1 368 | } 369 | } 370 | 371 | @objc func pauseAction() { 372 | self.repeatButton.alpha = 0 373 | self.pauseButton.alpha = 0 374 | self.playButton.alpha = 1 375 | self.videoProgressView.alpha = 1 376 | 377 | self.videoView.pause() 378 | } 379 | 380 | @objc func playAction() { 381 | #if os(iOS) 382 | self.repeatButton.alpha = 0 383 | self.pauseButton.alpha = 0 384 | self.playButton.alpha = 0 385 | self.videoProgressView.alpha = 0 386 | 387 | self.videoView.play() 388 | self.requestToHideOverlayIfNeeded() 389 | #else 390 | // We use the native video player in Apple TV because it provides us extra functionality that is not 391 | // provided in the custom player while at the same time it doesn't decrease the user experience since 392 | // it's not expected that the user will drag the video to dismiss it, something that we need to do on iOS. 393 | if let url = self.viewable?.url { 394 | self.playerViewController?.player?.currentItem?.removeObserver(self, forKeyPath: ViewableController.playerItemStatusKeyPath, context: nil) 395 | self.playerViewController = nil 396 | 397 | self.playerViewController = AVPlayerViewController(nibName: nil, bundle: nil) 398 | self.playerViewController?.player = AVPlayer(url: URL(string: url)!) 399 | 400 | guard let currentItem = self.playerViewController?.player?.currentItem else { return } 401 | currentItem.addObserver(self, forKeyPath: ViewableController.playerItemStatusKeyPath, options: [], context: nil) 402 | 403 | self.present(self.playerViewController!, animated: true) { 404 | self.playerViewController!.player?.play() 405 | } 406 | } 407 | #endif 408 | } 409 | 410 | func configure() { 411 | self.zoomingScrollView.maximumZoomScale = self.maxZoomScale() 412 | 413 | let viewFrame = self.view.frame 414 | let zoomScale = self.zoomingScrollView.zoomScale 415 | let frame = CGRect(x: viewFrame.origin.x, 416 | y: viewFrame.origin.y, 417 | width: zoomScale * viewFrame.width, 418 | height: zoomScale * viewFrame.height) 419 | 420 | self.zoomingScrollView.contentSize = frame.size 421 | self.imageView.frame = frame 422 | self.configureImageView() 423 | } 424 | 425 | func configureImageView() { 426 | guard let image = imageView.image else { 427 | centerImageView() 428 | return 429 | } 430 | 431 | let imageViewSize = imageView.frame.size 432 | let imageSize = image.size 433 | let realImageViewSize: CGSize 434 | 435 | if imageSize.width / imageSize.height > imageViewSize.width / imageViewSize.height { 436 | realImageViewSize = CGSize( 437 | width: imageViewSize.width, 438 | height: imageViewSize.width / imageSize.width * imageSize.height) 439 | } else { 440 | realImageViewSize = CGSize( 441 | width: imageViewSize.height / imageSize.height * imageSize.width, 442 | height: imageViewSize.height) 443 | } 444 | 445 | imageView.frame = CGRect(origin: CGPoint.zero, size: realImageViewSize) 446 | 447 | centerImageView() 448 | } 449 | 450 | func centerImageView() { 451 | let boundsSize = self.view.frame.size 452 | var imageViewFrame = imageView.frame 453 | 454 | if imageViewFrame.size.width < boundsSize.width { 455 | imageViewFrame.origin.x = (boundsSize.width - imageViewFrame.size.width) / 2.0 456 | } else { 457 | imageViewFrame.origin.x = 0.0 458 | } 459 | 460 | if imageViewFrame.size.height < boundsSize.height { 461 | imageViewFrame.origin.y = (boundsSize.height - imageViewFrame.size.height) / 2.0 462 | } else { 463 | imageViewFrame.origin.y = 0.0 464 | } 465 | 466 | imageView.frame = imageViewFrame 467 | } 468 | 469 | @objc func videoFinishedPlaying() { 470 | #if os(tvOS) 471 | guard let player = self.playerViewController?.player else { return } 472 | player.pause() 473 | self.playerViewController?.dismiss(animated: false, completion: nil) 474 | #endif 475 | } 476 | 477 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change _: [NSKeyValueChangeKey: Any]?, context _: UnsafeMutableRawPointer?) { 478 | guard let playerItem = object as? AVPlayerItem else { return } 479 | 480 | if let error = playerItem.error { 481 | self.handleVideoPlaybackError(error as NSError) 482 | } 483 | } 484 | 485 | func handleVideoPlaybackError(_ error: NSError) { 486 | self.delegate?.viewableController(self, didFailDisplayingVieweableWith: error) 487 | } 488 | 489 | @objc func repeatAction() { 490 | self.repeatButton.alpha = 0 491 | 492 | let overlayIsVisible = self.dataSource?.viewableControllerOverlayIsVisible(self) ?? false 493 | if overlayIsVisible { 494 | self.pauseButton.alpha = 1 495 | self.videoProgressView.alpha = 1 496 | } else { 497 | self.videoProgressView.alpha = 0 498 | } 499 | 500 | self.videoView.repeat() 501 | } 502 | 503 | func requestToHideOverlayIfNeeded() { 504 | let overlayIsVisible = self.dataSource?.viewableControllerOverlayIsVisible(self) ?? false 505 | if overlayIsVisible { 506 | self.delegate?.viewableControllerDidTapItem(self) 507 | } 508 | } 509 | 510 | var shouldDimPause: Bool = false 511 | var shouldDimPlay: Bool = false 512 | var shouldDimVideoProgress: Bool = false 513 | 514 | func dimControls(_ alpha: CGFloat) { 515 | if self.pauseButton.alpha == 1.0 { 516 | self.shouldDimPause = true 517 | } 518 | 519 | if self.playButton.alpha == 1.0 { 520 | self.shouldDimPlay = true 521 | } 522 | 523 | if self.videoProgressView.alpha == 1.0 { 524 | self.shouldDimVideoProgress = true 525 | } 526 | 527 | if self.shouldDimPause { 528 | self.pauseButton.alpha = alpha 529 | } 530 | 531 | if self.shouldDimPlay { 532 | self.playButton.alpha = alpha 533 | } 534 | 535 | if self.shouldDimVideoProgress { 536 | self.videoProgressView.alpha = alpha 537 | } 538 | 539 | if alpha == 1.0 { 540 | self.shouldDimPause = false 541 | self.shouldDimPlay = false 542 | self.shouldDimVideoProgress = false 543 | } 544 | } 545 | } 546 | 547 | extension ViewableController: UIScrollViewDelegate { 548 | 549 | func viewForZooming(in _: UIScrollView) -> UIView? { 550 | if self.viewable?.type == .image { 551 | return self.imageView 552 | } else { 553 | return nil 554 | } 555 | } 556 | 557 | func scrollViewDidZoom(_ scrollView: UIScrollView) { 558 | centerImageView() 559 | } 560 | } 561 | 562 | extension ViewableController: VideoViewDelegate { 563 | 564 | func videoViewDidStartPlaying(_: VideoView) { 565 | self.requestToHideOverlayIfNeeded() 566 | } 567 | 568 | func videoView(_: VideoView, didChangeProgress progress: Double, duration: Double) { 569 | self.videoProgressView.progress = progress 570 | self.videoProgressView.duration = duration 571 | } 572 | 573 | func videoViewDidFinishPlaying(_: VideoView, error: NSError?) { 574 | if let error = error { 575 | self.delegate?.viewableController(self, didFailDisplayingVieweableWith: error) 576 | } else { 577 | self.repeatButton.alpha = 1 578 | self.pauseButton.alpha = 0 579 | self.playButton.alpha = 0 580 | self.videoProgressView.alpha = 0 581 | } 582 | } 583 | } 584 | 585 | extension ViewableController: VideoProgressViewDelegate { 586 | func videoProgressViewDidBeginSeeking(_: VideoProgressView) { 587 | self.videoView.pause() 588 | } 589 | 590 | func videoProgressViewDidSeek(_: VideoProgressView, toDuration duration: Double) { 591 | self.videoView.stopPlayingAndSeekSmoothlyToTime(duration: duration) 592 | } 593 | 594 | func videoProgressViewDidEndSeeking(_: VideoProgressView) { 595 | self.videoView.play() 596 | } 597 | } 598 | -------------------------------------------------------------------------------- /Source/ViewableControllerContainer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol ViewableControllerContainer {} 4 | 5 | protocol ViewableControllerContainerDataSource: class { 6 | func numberOfPagesInViewableControllerContainer(_ viewableControllerContainer: ViewableControllerContainer) -> Int 7 | func viewableControllerContainer(_ viewableControllerContainer: ViewableControllerContainer, controllerAtIndex index: Int) -> UIViewController 8 | } 9 | 10 | protocol ViewableControllerContainerDelegate: class { 11 | func viewableControllerContainer(_ viewableControllerContainer: ViewableControllerContainer, didMoveToIndex index: Int) 12 | func viewableControllerContainer(_ viewableControllerContainer: ViewableControllerContainer, didMoveFromIndex index: Int) 13 | } 14 | 15 | -------------------------------------------------------------------------------- /Source/Viewer.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Source/Viewer.xcassets/close.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "close.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "close@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "close@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Source/Viewer.xcassets/close.imageset/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/close.imageset/close.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/close.imageset/close@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/close.imageset/close@2x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/close.imageset/close@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/close.imageset/close@3x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/dark-circle.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "dark-circle.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "dark-circle@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "dark-circle@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Source/Viewer.xcassets/dark-circle.imageset/dark-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/dark-circle.imageset/dark-circle.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/dark-circle.imageset/dark-circle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/dark-circle.imageset/dark-circle@2x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/dark-circle.imageset/dark-circle@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/dark-circle.imageset/dark-circle@3x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/pause.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "pause.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "pause@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "pause@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Source/Viewer.xcassets/pause.imageset/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/pause.imageset/pause.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/pause.imageset/pause@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/pause.imageset/pause@2x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/pause.imageset/pause@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/pause.imageset/pause@3x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/play.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "play.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "play@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "play@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Source/Viewer.xcassets/play.imageset/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/play.imageset/play.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/play.imageset/play@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/play.imageset/play@2x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/play.imageset/play@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/play.imageset/play@3x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/repeat.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "repeat.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "repeat@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "repeat@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Source/Viewer.xcassets/repeat.imageset/repeat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/repeat.imageset/repeat.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/repeat.imageset/repeat@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/repeat.imageset/repeat@2x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/repeat.imageset/repeat@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/repeat.imageset/repeat@3x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/seek.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "seek.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "seek@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "seek@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Source/Viewer.xcassets/seek.imageset/seek.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/seek.imageset/seek.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/seek.imageset/seek@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/seek.imageset/seek@2x.png -------------------------------------------------------------------------------- /Source/Viewer.xcassets/seek.imageset/seek@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3lvis/Viewer/aca4e30e1e3085a0c5cdbcb3a69dfa5802e55e82/Source/Viewer.xcassets/seek.imageset/seek@3x.png -------------------------------------------------------------------------------- /Source/ViewerAssets.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ViewerAssets { 4 | static let bundle = Bundle(for: ViewerAssets.self) 5 | } 6 | 7 | extension UIImage { 8 | static var darkCircle = UIImage(name: "dark-circle") 9 | static var pause = UIImage(name: "pause") 10 | static var play = UIImage(name: "play") 11 | static var `repeat` = UIImage(name: "repeat") 12 | static var seek = UIImage(name: "seek") 13 | public static var close = UIImage(name: "close") 14 | 15 | convenience init(name: String) { 16 | self.init(named: name, in: ViewerAssets.bundle, compatibleWith: nil)! 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Source/ViewerController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import CoreData 3 | 4 | public protocol ViewerControllerDataSource: class { 5 | func numberOfItemsInViewerController(_ viewerController: ViewerController) -> Int 6 | func viewerController(_ viewerController: ViewerController, viewableAt indexPath: IndexPath) -> Viewable 7 | } 8 | 9 | public protocol ViewerControllerDelegate: class { 10 | func viewerController(_ viewerController: ViewerController, didChangeFocusTo indexPath: IndexPath) 11 | func viewerControllerDidDismiss(_ viewerController: ViewerController) 12 | func viewerController(_ viewerController: ViewerController, didFailDisplayingViewableAt indexPath: IndexPath, error: NSError) 13 | func viewerController(_ viewerController: ViewerController, didLongPressViewableAt indexPath: IndexPath) 14 | } 15 | 16 | /// The ViewerController takes care of displaying the user's photos and videos in full-screen. You can swipe right or left to navigate between them. 17 | public class ViewerController: UIViewController { 18 | static let domain = "com.3lvis.Viewer" 19 | fileprivate static let HeaderHeight = CGFloat(64) 20 | fileprivate static let FooterHeight = CGFloat(50) 21 | fileprivate static let DraggingMargin = CGFloat(60) 22 | 23 | fileprivate var isSlideshow: Bool 24 | 25 | public init(initialIndexPath: IndexPath, collectionView: UICollectionView, isSlideshow: Bool = false) { 26 | self.initialIndexPath = initialIndexPath 27 | self.currentIndexPath = initialIndexPath 28 | self.collectionView = collectionView 29 | 30 | self.proposedCurrentIndexPath = initialIndexPath 31 | self.isSlideshow = isSlideshow 32 | 33 | super.init(nibName: nil, bundle: nil) 34 | 35 | self.view.backgroundColor = .clear 36 | self.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 37 | self.modalPresentationStyle = .overCurrentContext 38 | #if os(iOS) 39 | self.modalPresentationCapturesStatusBarAppearance = true 40 | #endif 41 | } 42 | 43 | fileprivate var proposedCurrentIndexPath: IndexPath 44 | 45 | public required init?(coder _: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | public weak var delegate: ViewerControllerDelegate? 50 | public weak var dataSource: ViewerControllerDataSource? 51 | 52 | /** 53 | Flag that tells the viewer controller to autoplay videos on focus 54 | */ 55 | public var autoplayVideos: Bool = false 56 | 57 | /** 58 | Viewable background color 59 | */ 60 | public var viewableBackgroundColor: UIColor = .black 61 | /** 62 | Cache for the reused ViewableControllers 63 | */ 64 | fileprivate let viewableControllerCache = NSCache() 65 | 66 | /** 67 | Temporary variable used to present the initial controller on viewDidAppear 68 | */ 69 | fileprivate var initialIndexPath: IndexPath 70 | 71 | /** 72 | The UICollectionView to be used when dismissing and presenting elements 73 | */ 74 | fileprivate unowned var collectionView: UICollectionView 75 | 76 | /** 77 | CGPoint used for diffing the panning on an image 78 | */ 79 | fileprivate var originalDraggedCenter = CGPoint.zero 80 | 81 | /** 82 | Used for doing a different animation when dismissing in the middle of a dragging gesture 83 | */ 84 | fileprivate var isDragging = false 85 | 86 | /** 87 | Keeps track of where the status bar should be hidden or not 88 | */ 89 | fileprivate var shouldHideStatusBar = false 90 | 91 | /** 92 | Keeps track of where the status bar should be light or not 93 | */ 94 | public var shouldUseLightStatusBar = true 95 | 96 | /** 97 | Critical button visibility state tracker, it's used to force the buttons to keep being hidden when they are toggled 98 | */ 99 | fileprivate var buttonsAreVisible = false 100 | 101 | /** 102 | Tracks the index for the current viewer item controller 103 | */ 104 | fileprivate(set) public var currentIndexPath: IndexPath 105 | 106 | /** 107 | A helper to prevent the paginated scroll view to be set up twice when is presented 108 | */ 109 | fileprivate(set) public var isPresented = false 110 | 111 | fileprivate lazy var overlayView: UIView = { 112 | let view = UIView(frame: UIScreen.main.bounds) 113 | view.backgroundColor = self.viewableBackgroundColor 114 | view.alpha = 0 115 | view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 116 | 117 | return view 118 | }() 119 | 120 | fileprivate lazy var pageController: UIPageViewController = { 121 | let controller = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) 122 | controller.dataSource = self 123 | controller.delegate = self 124 | 125 | return controller 126 | }() 127 | 128 | public var headerView: UIView? 129 | 130 | public var footerView: UIView? 131 | 132 | private lazy var defaultHeaderView: DefaultHeaderView = { 133 | let defaultHeaderView = DefaultHeaderView() 134 | defaultHeaderView.delegate = self 135 | defaultHeaderView.translatesAutoresizingMaskIntoConstraints = false 136 | defaultHeaderView.alpha = 0 137 | return defaultHeaderView 138 | }() 139 | 140 | lazy var scrollView: PaginatedScrollView = { 141 | let view = PaginatedScrollView(frame: self.view.frame, parentController: self, initialPage: self.initialIndexPath.totalRow(self.collectionView)) 142 | view.viewDataSource = self 143 | view.viewDelegate = self 144 | view.backgroundColor = .clear 145 | 146 | return view 147 | }() 148 | 149 | lazy var slideshowView: SlideshowView = { 150 | let view = SlideshowView(frame: self.view.frame, parentController: self, initialPage: self.initialIndexPath.totalRow(self.collectionView)) 151 | view.dataSource = self 152 | view.delegate = self 153 | view.backgroundColor = .clear 154 | 155 | return view 156 | }() 157 | 158 | // MARK: View Lifecycle 159 | 160 | public override func viewDidLoad() { 161 | super.viewDidLoad() 162 | 163 | #if os(iOS) 164 | self.view.addSubview(self.scrollView) 165 | #else 166 | let menuTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.menu(gesture:))) 167 | menuTapRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.menu.rawValue)] 168 | self.view.addGestureRecognizer(menuTapRecognizer) 169 | 170 | if self.isSlideshow { 171 | self.view.addSubview(self.slideshowView) 172 | } else { 173 | self.addChild(self.pageController) 174 | self.pageController.view.frame = UIScreen.main.bounds 175 | self.view.addSubview(self.pageController.view) 176 | self.pageController.didMove(toParent: self) 177 | 178 | let playPauseTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.playPause(gesture:))) 179 | playPauseTapRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.playPause.rawValue)] 180 | self.view.addGestureRecognizer(playPauseTapRecognizer) 181 | 182 | let selectTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.select(gesture:))) 183 | selectTapRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.select.rawValue)] 184 | self.view.addGestureRecognizer(selectTapRecognizer) 185 | 186 | let rightSwipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(rightSwipe(gesture:))) 187 | rightSwipeRecognizer.direction = .right 188 | self.view.addGestureRecognizer(rightSwipeRecognizer) 189 | 190 | let leftSwipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(leftSwipe(gesture:))) 191 | leftSwipeRecognizer.direction = .left 192 | self.view.addGestureRecognizer(leftSwipeRecognizer) 193 | } 194 | #endif 195 | 196 | let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPress(gesture:))) 197 | self.view.addGestureRecognizer(recognizer) 198 | } 199 | 200 | #if os(tvOS) 201 | @objc func menu(gesture: UITapGestureRecognizer) { 202 | guard gesture.state == .ended else { return } 203 | 204 | self.dismiss(nil) 205 | } 206 | 207 | @objc func playPause(gesture: UITapGestureRecognizer) { 208 | guard gesture.state == .ended else { return } 209 | 210 | self.playIfVideo() 211 | } 212 | 213 | @objc func select(gesture: UITapGestureRecognizer) { 214 | guard gesture.state == .ended else { return } 215 | 216 | self.playIfVideo() 217 | } 218 | 219 | func playIfVideo() { 220 | let viewableController = self.findOrCreateViewableController(self.currentIndexPath) 221 | let isVideo = viewableController.viewable?.type == .video 222 | if isVideo { 223 | viewableController.play() 224 | } 225 | } 226 | 227 | @objc func rightSwipe(gesture: UISwipeGestureRecognizer) { 228 | guard gesture.state == .ended else { return } 229 | 230 | self.scrollView.goRight() 231 | } 232 | 233 | @objc func leftSwipe(gesture: UISwipeGestureRecognizer) { 234 | guard gesture.state == .ended else { return } 235 | 236 | self.scrollView.goLeft() 237 | } 238 | 239 | public override func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool { 240 | let result = super.shouldUpdateFocus(in: context) 241 | if context.focusHeading == .up { 242 | return false 243 | } 244 | return result 245 | } 246 | #endif 247 | 248 | @objc func longPress(gesture: UILongPressGestureRecognizer) { 249 | guard gesture.state == .began else { return } 250 | 251 | self.delegate?.viewerController(self, didLongPressViewableAt: self.currentIndexPath) 252 | } 253 | 254 | public override func viewWillLayoutSubviews() { 255 | super.viewWillLayoutSubviews() 256 | 257 | if self.isPresented { 258 | if self.isSlideshow { 259 | self.slideshowView.configure() 260 | } else { 261 | self.scrollView.configure() 262 | } 263 | if !self.collectionView.indexPathsForVisibleItems.contains(self.currentIndexPath) && self.collectionView.numberOfSections > self.currentIndexPath.section && self.collectionView.numberOfItems(inSection: self.currentIndexPath.section) > self.currentIndexPath.item { 264 | self.collectionView.scrollToItem(at: self.currentIndexPath, at: .bottom, animated: true) 265 | } 266 | } 267 | } 268 | 269 | public override func viewDidAppear(_ animated: Bool) { 270 | super.viewDidAppear(animated) 271 | 272 | self.present(with: self.initialIndexPath, completion: nil) 273 | } 274 | 275 | public func reload(at indexPath: IndexPath) { 276 | let viewableController = self.findOrCreateViewableController(indexPath) 277 | viewableController.display() 278 | } 279 | } 280 | 281 | extension ViewerController { 282 | #if os(iOS) 283 | public override var prefersStatusBarHidden: Bool { 284 | let orientation = UIApplication.shared.statusBarOrientation 285 | if orientation.isLandscape { 286 | return true 287 | } 288 | 289 | return self.shouldHideStatusBar 290 | } 291 | 292 | public override var preferredStatusBarStyle: UIStatusBarStyle { 293 | if self.shouldUseLightStatusBar { 294 | return .lightContent 295 | } else { 296 | return self.presentingViewController?.preferredStatusBarStyle ?? .default 297 | } 298 | } 299 | #endif 300 | 301 | private func presentedViewCopy() -> UIImageView { 302 | let presentedView = UIImageView() 303 | presentedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 304 | presentedView.contentMode = .scaleAspectFill 305 | presentedView.clipsToBounds = true 306 | 307 | return presentedView 308 | } 309 | 310 | fileprivate func findOrCreateViewableController(_ indexPath: IndexPath) -> ViewableController { 311 | let viewable = self.dataSource!.viewerController(self, viewableAt: indexPath) 312 | var viewableController: ViewableController 313 | 314 | if let cachedController = self.viewableControllerCache.object(forKey: indexPath.description as NSString) { 315 | viewableController = cachedController 316 | } else { 317 | viewableController = ViewableController() 318 | viewableController.delegate = self 319 | viewableController.dataSource = self 320 | 321 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(ViewerController.panAction(_:))) 322 | gesture.delegate = self 323 | viewableController.imageView.addGestureRecognizer(gesture) 324 | 325 | self.viewableControllerCache.setObject(viewableController, forKey: indexPath.description as NSString) 326 | } 327 | 328 | viewableController.update(with: viewable, at: indexPath) 329 | viewableController.viewableBackgroundColor = self.viewableBackgroundColor 330 | return viewableController 331 | } 332 | 333 | fileprivate func toggleButtons(_ shouldShow: Bool) { 334 | UIView.animate(withDuration: 0.3, animations: { 335 | #if os(iOS) 336 | self.setNeedsStatusBarAppearanceUpdate() 337 | #endif 338 | self.headerView?.alpha = shouldShow ? 1 : 0 339 | self.footerView?.alpha = shouldShow ? 1 : 0 340 | }) 341 | } 342 | 343 | private func fadeButtons(_ alpha: CGFloat) { 344 | self.headerView?.alpha = alpha 345 | self.footerView?.alpha = alpha 346 | } 347 | 348 | fileprivate func present(with indexPath: IndexPath, completion: (() -> Void)?) { 349 | guard let selectedCell = self.collectionView.cellForItem(at: indexPath) else { return } 350 | 351 | let viewable = self.dataSource!.viewerController(self, viewableAt: indexPath) 352 | let image = viewable.placeholder 353 | selectedCell.alpha = 0 354 | 355 | let presentedView = self.presentedViewCopy() 356 | presentedView.frame = self.view.convert(selectedCell.frame, from: self.collectionView) 357 | presentedView.image = image 358 | 359 | self.view.addSubview(self.overlayView) 360 | self.view.addSubview(presentedView) 361 | 362 | if self.headerView == nil { 363 | self.headerView = defaultHeaderView 364 | } 365 | 366 | if let headerView = self.headerView { 367 | headerView.translatesAutoresizingMaskIntoConstraints = false 368 | headerView.alpha = 0 369 | self.view.addSubview(headerView) 370 | 371 | NSLayoutConstraint.activate([ 372 | headerView.topAnchor.constraint(equalTo: view.compatibleTopAnchor), 373 | headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 374 | headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 375 | headerView.heightAnchor.constraint(equalToConstant: ViewerController.HeaderHeight) 376 | ]) 377 | } 378 | 379 | if let footerView = self.footerView { 380 | footerView.translatesAutoresizingMaskIntoConstraints = false 381 | footerView.alpha = 0 382 | self.view.addSubview(footerView) 383 | 384 | NSLayoutConstraint.activate([ 385 | footerView.bottomAnchor.constraint(equalTo: view.compatibleBottomAnchor), 386 | footerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 387 | footerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 388 | footerView.heightAnchor.constraint(equalToConstant: ViewerController.FooterHeight) 389 | ]) 390 | } 391 | 392 | let centeredImageFrame = image.centeredFrame() 393 | UIView.animate(withDuration: 0.25, animations: { 394 | self.presentingViewController?.tabBarController?.tabBar.alpha = 0 395 | self.overlayView.alpha = 1.0 396 | #if os(iOS) 397 | self.setNeedsStatusBarAppearanceUpdate() 398 | #endif 399 | presentedView.frame = centeredImageFrame 400 | }, completion: { _ in 401 | self.toggleButtons(true) 402 | self.buttonsAreVisible = true 403 | self.currentIndexPath = indexPath 404 | presentedView.removeFromSuperview() 405 | self.overlayView.removeFromSuperview() 406 | self.view.backgroundColor = .black 407 | 408 | self.isPresented = true 409 | let controller = self.findOrCreateViewableController(indexPath) 410 | controller.display() 411 | 412 | self.delegate?.viewerController(self, didChangeFocusTo: indexPath) 413 | 414 | #if os(iOS) 415 | completion?() 416 | #else 417 | if self.isSlideshow { 418 | self.slideshowView.start() 419 | 420 | UIApplication.shared.isIdleTimerDisabled = true 421 | } else { 422 | self.pageController.setViewControllers([controller], direction: .forward, animated: false, completion: { _ in 423 | completion?() 424 | }) 425 | } 426 | #endif 427 | }) 428 | } 429 | 430 | public func dismiss(_ completion: (() -> Void)?) { 431 | let controller = self.findOrCreateViewableController(self.currentIndexPath) 432 | self.dismiss(controller, completion: completion) 433 | } 434 | 435 | private func dismiss(_ viewableController: ViewableController, completion: (() -> Void)?) { 436 | if self.isSlideshow { 437 | self.slideshowView.stop() 438 | 439 | UIApplication.shared.isIdleTimerDisabled = false 440 | } 441 | 442 | guard let indexPath = viewableController.indexPath else { return } 443 | 444 | guard let selectedCellFrame = self.collectionView.layoutAttributesForItem(at: indexPath)?.frame else { return } 445 | 446 | let viewable = self.dataSource!.viewerController(self, viewableAt: indexPath) 447 | let image = viewable.placeholder 448 | viewableController.imageView.alpha = 0 449 | viewableController.view.backgroundColor = .clear 450 | viewableController.willDismiss() 451 | 452 | self.view.alpha = 0 453 | self.fadeButtons(0) 454 | self.buttonsAreVisible = false 455 | self.updateHiddenCellsUsingVisibleIndexPath(self.currentIndexPath) 456 | 457 | self.shouldHideStatusBar = false 458 | #if os(iOS) 459 | self.setNeedsStatusBarAppearanceUpdate() 460 | #endif 461 | self.overlayView.alpha = self.isDragging ? viewableController.view.backgroundColor!.cgColor.alpha : 1.0 462 | self.overlayView.frame = UIScreen.main.bounds 463 | 464 | let presentedView = self.presentedViewCopy() 465 | presentedView.frame = image.centeredFrame() 466 | presentedView.image = image 467 | if self.isDragging { 468 | presentedView.center = viewableController.imageView.center 469 | } 470 | 471 | let window = self.applicationWindow() 472 | window.addSubview(self.overlayView) 473 | window.addSubview(presentedView) 474 | self.shouldUseLightStatusBar = false 475 | 476 | UIView.animate(withDuration: 0.30, animations: { 477 | self.presentingViewController?.tabBarController?.tabBar.alpha = 1 478 | self.overlayView.alpha = 0.0 479 | #if os(iOS) 480 | self.setNeedsStatusBarAppearanceUpdate() 481 | #endif 482 | presentedView.frame = self.view.convert(selectedCellFrame, from: self.collectionView) 483 | }, completion: { _ in 484 | if let existingCell = self.collectionView.cellForItem(at: indexPath) { 485 | existingCell.alpha = 1 486 | } 487 | 488 | self.headerView?.removeFromSuperview() 489 | self.footerView?.removeFromSuperview() 490 | presentedView.removeFromSuperview() 491 | self.overlayView.removeFromSuperview() 492 | self.dismiss(animated: false, completion: nil) 493 | 494 | // A small delay is required to avoid racing conditions between the dismissing animation and the 495 | // state change after the animation is completed. 496 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 497 | self.isPresented = false 498 | self.delegate?.viewerControllerDidDismiss(self) 499 | completion?() 500 | } 501 | }) 502 | } 503 | 504 | @objc func panAction(_ gesture: UIPanGestureRecognizer) { 505 | let controller = self.findOrCreateViewableController(self.currentIndexPath) 506 | guard !controller.hasZoomed else { return } 507 | 508 | let viewHeight = controller.imageView.frame.size.height 509 | let viewHalfHeight = viewHeight / 2 510 | var translatedPoint = gesture.translation(in: controller.imageView) 511 | 512 | if gesture.state == .began { 513 | self.shouldHideStatusBar = false 514 | #if os(iOS) 515 | self.setNeedsStatusBarAppearanceUpdate() 516 | #endif 517 | self.view.backgroundColor = .clear 518 | self.originalDraggedCenter = controller.imageView.center 519 | self.isDragging = true 520 | self.updateHiddenCellsUsingVisibleIndexPath(self.currentIndexPath) 521 | controller.willDismiss() 522 | } 523 | 524 | translatedPoint = CGPoint(x: self.originalDraggedCenter.x, y: self.originalDraggedCenter.y + translatedPoint.y) 525 | let alphaDiff = ((translatedPoint.y - viewHalfHeight) / viewHalfHeight) * 2.5 526 | let isDraggedUp = translatedPoint.y < viewHalfHeight 527 | let alpha = isDraggedUp ? 1 + alphaDiff : 1 - alphaDiff 528 | 529 | controller.dimControls(alpha) 530 | controller.imageView.center = translatedPoint 531 | controller.view.backgroundColor = self.viewableBackgroundColor.withAlphaComponent(alpha) 532 | 533 | if self.buttonsAreVisible { 534 | self.fadeButtons(alpha) 535 | } 536 | 537 | if gesture.state == .ended { 538 | let centerAboveDraggingArea = controller.imageView.center.y < viewHalfHeight - ViewerController.DraggingMargin 539 | let centerBellowDraggingArea = controller.imageView.center.y > viewHalfHeight + ViewerController.DraggingMargin 540 | if centerAboveDraggingArea || centerBellowDraggingArea { 541 | self.dismiss(controller, completion: nil) 542 | } else { 543 | self.isDragging = false 544 | UIView.animate(withDuration: 0.20, animations: { 545 | controller.imageView.center = self.originalDraggedCenter 546 | controller.view.backgroundColor = self.viewableBackgroundColor 547 | controller.dimControls(1.0) 548 | 549 | if self.buttonsAreVisible { 550 | self.fadeButtons(1) 551 | } 552 | 553 | self.shouldHideStatusBar = !self.buttonsAreVisible 554 | 555 | #if os(iOS) 556 | self.setNeedsStatusBarAppearanceUpdate() 557 | #endif 558 | }, completion: { _ in 559 | controller.display() 560 | self.view.backgroundColor = self.viewableBackgroundColor 561 | }) 562 | } 563 | } 564 | } 565 | 566 | fileprivate func centerElementIfNotVisible(_ indexPath: IndexPath, animated: Bool) { 567 | if !self.collectionView.indexPathsForVisibleItems.contains(indexPath) { 568 | self.collectionView.scrollToItem(at: indexPath, at: .top, animated: animated) 569 | } 570 | } 571 | 572 | private func updateHiddenCellsUsingVisibleIndexPath(_ visibleIndexPath: IndexPath) { 573 | for indexPath in self.collectionView.indexPathsForVisibleItems { 574 | if let cell = self.collectionView.cellForItem(at: indexPath) { 575 | cell.alpha = indexPath == visibleIndexPath ? 0 : 1 576 | } 577 | } 578 | } 579 | 580 | fileprivate func evaluateCellVisibility(collectionView: UICollectionView, currentIndexPath: IndexPath, upcomingIndexPath: IndexPath) { 581 | if !collectionView.indexPathsForVisibleItems.contains(upcomingIndexPath) { 582 | var position: UICollectionView.ScrollPosition? 583 | if currentIndexPath.compareDirection(upcomingIndexPath) == .forward { 584 | position = .bottom 585 | } else if currentIndexPath.compareDirection(upcomingIndexPath) == .backward { 586 | position = .top 587 | } 588 | if let position = position { 589 | collectionView.scrollToItem(at: upcomingIndexPath, at: position, animated: true) 590 | } 591 | } 592 | } 593 | } 594 | 595 | extension ViewerController: ViewableControllerDelegate { 596 | 597 | func viewableControllerDidTapItem(_: ViewableController) { 598 | self.shouldHideStatusBar = !self.shouldHideStatusBar 599 | self.buttonsAreVisible = !self.buttonsAreVisible 600 | self.toggleButtons(self.buttonsAreVisible) 601 | } 602 | 603 | func viewableController(_: ViewableController, didFailDisplayingVieweableWith error: NSError) { 604 | self.delegate?.viewerController(self, didFailDisplayingViewableAt: self.currentIndexPath, error: error) 605 | } 606 | } 607 | 608 | extension ViewerController: ViewableControllerDataSource { 609 | 610 | func viewableControllerOverlayIsVisible(_: ViewableController) -> Bool { 611 | return self.buttonsAreVisible 612 | } 613 | 614 | func viewableControllerIsFocused(_ viewableController: ViewableController) -> Bool { 615 | let focusedViewableController = self.findOrCreateViewableController(self.currentIndexPath) 616 | 617 | return viewableController == focusedViewableController 618 | } 619 | 620 | func viewableControllerShouldAutoplayVideo(_: ViewableController) -> Bool { 621 | return self.autoplayVideos 622 | } 623 | } 624 | 625 | extension ViewerController: UIGestureRecognizerDelegate { 626 | 627 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 628 | if gestureRecognizer is UIPanGestureRecognizer { 629 | let panGestureRecognizer = gestureRecognizer as! UIPanGestureRecognizer 630 | let velocity = panGestureRecognizer.velocity(in: panGestureRecognizer.view!) 631 | let allowOnlyVerticalScrolls = abs(velocity.y) > abs(velocity.x) 632 | 633 | return allowOnlyVerticalScrolls 634 | } 635 | 636 | return true 637 | } 638 | } 639 | 640 | extension ViewerController: ViewableControllerContainerDataSource { 641 | func numberOfPagesInViewableControllerContainer(_ viewableControllerContainer: ViewableControllerContainer) -> Int { 642 | return self.dataSource?.numberOfItemsInViewerController(self) ?? 0 643 | } 644 | 645 | func viewableControllerContainer(_ viewableControllerContainer: ViewableControllerContainer, controllerAtIndex index: Int) -> UIViewController { 646 | let indexPath = IndexPath.indexPathForIndex(self.collectionView, index: index)! 647 | 648 | return self.findOrCreateViewableController(indexPath) 649 | } 650 | } 651 | 652 | extension ViewerController: ViewableControllerContainerDelegate { 653 | func viewableControllerContainer(_ viewableControllerContainer: ViewableControllerContainer, didMoveToIndex index: Int) { 654 | let indexPath = IndexPath.indexPathForIndex(self.collectionView, index: index)! 655 | self.evaluateCellVisibility(collectionView: self.collectionView, currentIndexPath: self.currentIndexPath, upcomingIndexPath: indexPath) 656 | self.currentIndexPath = indexPath 657 | self.delegate?.viewerController(self, didChangeFocusTo: indexPath) 658 | let viewableController = self.findOrCreateViewableController(indexPath) 659 | viewableController.display() 660 | } 661 | 662 | func viewableControllerContainer(_ viewableControllerContainer: ViewableControllerContainer, didMoveFromIndex index: Int) { 663 | let indexPath = IndexPath.indexPathForIndex(self.collectionView, index: index)! 664 | let viewableController = self.findOrCreateViewableController(indexPath) 665 | viewableController.willDismiss() 666 | } 667 | } 668 | 669 | extension ViewerController: UIPageViewControllerDelegate { 670 | public func pageViewController(_: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { 671 | guard let controllers = pendingViewControllers as? [ViewableController] else { fatalError() } 672 | 673 | for controller in controllers { 674 | self.delegate?.viewerController(self, didChangeFocusTo: controller.indexPath!) 675 | self.proposedCurrentIndexPath = controller.indexPath! 676 | } 677 | } 678 | 679 | public func pageViewController(_: UIPageViewController, didFinishAnimating _: Bool, previousViewControllers _: [UIViewController], transitionCompleted completed: Bool) { 680 | if completed { 681 | self.delegate?.viewerController(self, didChangeFocusTo: self.proposedCurrentIndexPath) 682 | self.currentIndexPath = self.proposedCurrentIndexPath 683 | self.delegate?.viewerController(self, didChangeFocusTo: self.currentIndexPath) 684 | self.centerElementIfNotVisible(self.currentIndexPath, animated: false) 685 | } 686 | } 687 | } 688 | 689 | extension ViewerController: UIPageViewControllerDataSource { 690 | public func pageViewController(_: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { 691 | if let viewerItemController = viewController as? ViewableController, let newIndexPath = viewerItemController.indexPath?.previous(self.collectionView) { 692 | let controller = self.findOrCreateViewableController(newIndexPath) 693 | controller.display() 694 | 695 | return controller 696 | } 697 | 698 | return nil 699 | } 700 | 701 | public func pageViewController(_: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { 702 | if let viewerItemController = viewController as? ViewableController, let newIndexPath = viewerItemController.indexPath?.next(self.collectionView) { 703 | let controller = self.findOrCreateViewableController(newIndexPath) 704 | controller.display() 705 | 706 | return controller 707 | } 708 | 709 | return nil 710 | } 711 | } 712 | 713 | extension ViewerController: DefaultHeaderViewDelegate { 714 | func headerView(_ headerView: DefaultHeaderView, didPressClearButton button: UIButton) { 715 | dismiss(nil) 716 | } 717 | } 718 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Tests/Tests.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import XCTest 3 | 4 | class Tests: XCTestCase { 5 | 6 | func test() { 7 | let ofCourse = true 8 | XCTAssertEqual(ofCourse, true) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Viewer.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "Viewer" 3 | s.summary = "Image viewer (or Lightbox) with support for local and remote videos and images" 4 | s.version = "4.3.0" 5 | s.homepage = "https://github.com/3lvis/Viewer" 6 | s.license = 'MIT' 7 | s.author = { "Elvis Nuñez" => "elvisnunez@me.com" } 8 | s.source = { :git => "https://github.com/3lvis/Viewer.git", :tag => s.version.to_s } 9 | s.social_media_url = 'https://twitter.com/3lvis' 10 | s.ios.deployment_target = '11.0' 11 | s.tvos.deployment_target = '11.0' 12 | s.requires_arc = true 13 | s.source_files = 'Source' 14 | s.resources = "Source/*.xcassets" 15 | s.frameworks = 'UIKit' 16 | s.swift_version = '5.0' 17 | end 18 | -------------------------------------------------------------------------------- /Viewer/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Viewer/Viewer.h: -------------------------------------------------------------------------------- 1 | // 2 | // Viewer.h 3 | // Viewer 4 | // 5 | // Created by Alexey Talkan on 17/07/2019. 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for Viewer. 11 | FOUNDATION_EXPORT double ViewerVersionNumber; 12 | 13 | //! Project version string for Viewer. 14 | FOUNDATION_EXPORT const unsigned char ViewerVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /iOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | static let IsLightStatusBar = false 6 | 7 | var window: UIWindow? 8 | 9 | public func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 10 | self.window = UIWindow(frame: UIScreen.main.bounds) 11 | 12 | let localController = PhotosController(dataSourceType: .local) 13 | localController.title = "Local" 14 | let localNavigationController = UINavigationController(rootViewController: localController) 15 | 16 | let remoteController = PhotosController(dataSourceType: .remote) 17 | remoteController.title = "Remote" 18 | let remoteNavigationController = UINavigationController(rootViewController: remoteController) 19 | 20 | if AppDelegate.IsLightStatusBar { 21 | UINavigationBar.appearance().barTintColor = .orange 22 | UINavigationBar.appearance().titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white] 23 | remoteNavigationController.navigationBar.barStyle = .black 24 | localNavigationController.navigationBar.barStyle = .black 25 | } 26 | 27 | let tabBarController = UITabBarController() 28 | tabBarController.setViewControllers([localNavigationController, remoteNavigationController], animated: false) 29 | 30 | self.window!.rootViewController = tabBarController 31 | self.window!.makeKeyAndVisible() 32 | 33 | return true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /iOS/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | Viewer 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | NSAppTransportSecurity 26 | 27 | NSAllowsArbitraryLoads 28 | 29 | 30 | NSPhotoLibraryUsageDescription 31 | Access to your Camera Roll will let us show your beautiful pictures in full screen. 32 | UIAppFonts 33 | 34 | DINNextLTPro-Regular.otf 35 | 36 | UILaunchStoryboardName 37 | LaunchScreen 38 | UISupportedInterfaceOrientations 39 | 40 | UIInterfaceOrientationPortrait 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | UIInterfaceOrientationPortraitUpsideDown 44 | 45 | UIViewControllerBasedStatusBarAppearance 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /tvOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | static let IsLightStatusBar = false 6 | 7 | var window: UIWindow? 8 | 9 | public func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 10 | self.window = UIWindow(frame: UIScreen.main.bounds) 11 | 12 | let remoteController = PhotosController(dataSourceType: .remote) 13 | remoteController.title = "Remote" 14 | let remoteNavigationController = UINavigationController(rootViewController: remoteController) 15 | 16 | let tabBarController = UITabBarController() 17 | tabBarController.setViewControllers([remoteNavigationController], animated: false) 18 | 19 | self.window!.rootViewController = tabBarController 20 | self.window!.makeKeyAndVisible() 21 | 22 | return true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tvOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | NSAppTransportSecurity 24 | 25 | NSAllowsArbitraryLoads 26 | 27 | 28 | UIRequiredDeviceCapabilities 29 | 30 | arm64 31 | 32 | UIUserInterfaceStyle 33 | Automatic 34 | 35 | 36 | --------------------------------------------------------------------------------