├── .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 | 
2 |
3 |
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 |
--------------------------------------------------------------------------------