├── .gitignore ├── static ├── sample.gif └── sample.png ├── Tests ├── LinuxMain.swift └── PhotoLibraryPickerTests │ ├── XCTestManifests.swift │ └── PhotoLibraryPickerTests.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.swift ├── README.md └── Sources └── PhotoLibraryPicker └── PhotoLibraryPicker.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /static/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moifort/swiftUI-photo-library-picker/HEAD/static/sample.gif -------------------------------------------------------------------------------- /static/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moifort/swiftUI-photo-library-picker/HEAD/static/sample.png -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import PhotoLibraryPickerTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += PhotoLibraryPickerTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/PhotoLibraryPickerTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(PhotoLibraryPickerTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/PhotoLibraryPickerTests/PhotoLibraryPickerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PhotoLibraryPicker 3 | 4 | final class PhotoLibraryPickerTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | } 10 | 11 | static var allTests = [ 12 | ("testExample", testExample), 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "PhotoLibraryPicker", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library( 14 | name: "PhotoLibraryPicker", 15 | targets: ["PhotoLibraryPicker"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 24 | .target( 25 | name: "PhotoLibraryPicker", 26 | dependencies: []), 27 | .testTarget( 28 | name: "PhotoLibraryPickerTests", 29 | dependencies: ["PhotoLibraryPicker"]), 30 | ], 31 | swiftLanguageVersions: [ 32 | .version("5.1") 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/moifort/swiftUI-photo-library-picker) 2 | # Photo Library Picker for SwiftUI 3 | 4 | ![sample](./static/sample.png) 5 | ![sample](./static/sample.gif) 6 | 7 | ## Installation with Swift Package Manager 8 | 9 | Swift Package Manager is integrated within Xcode 11: 10 | 11 | 1. File → Swift Packages → Add Package Dependency... 12 | 2. Paste the repository URL: https://github.com/moifort/swiftUI-photo-library-picker.git 13 | 3. Add `NSPhotoLibraryUsageDescription` to `info.plist` 14 | 15 | ## Usage 16 | 17 | ```swift 18 | import SwiftUI 19 | import PhotoLibraryPicker // Add import 20 | 21 | struct ContentView : View { 22 | @State var showActionSheet: Bool = false 23 | @State var pictures = [Picture]() 24 | 25 | var body: some View { 26 | VStack { 27 | Button(action: {self.showActionSheet.toggle()}) { 28 | Image(systemName: "plus") 29 | .padding() 30 | .background(Color.secondary) 31 | .mask(Circle()) 32 | }.sheet(isPresented: self.$showActionSheet) {PhotoLibraryPicker(self.$pictures)} 33 | List { 34 | ForEach(pictures) { picture in 35 | picture.toImage() // You can fix the size by default width: 100, height: 100 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | 43 | struct ContentView_Previews: PreviewProvider { 44 | static var previews: some View { 45 | Group { 46 | ContentView().environment(\.colorScheme, .dark) 47 | ContentView() 48 | } 49 | 50 | } 51 | } 52 | ``` 53 | 54 | ## Thanks 55 | 56 | * To @dillidon for this [project](https://github.com/dillidon/alerts-and-pickers) 57 | 58 | -------------------------------------------------------------------------------- /Sources/PhotoLibraryPicker/PhotoLibraryPicker.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | import UIKit 4 | import Photos 5 | import AudioToolbox 6 | 7 | extension UIApplication { 8 | 9 | class func topViewController(_ viewController: UIViewController? = UIApplication.shared.windows.first?.rootViewController) -> UIViewController? { 10 | if let nav = viewController as? UINavigationController { 11 | return topViewController(nav.visibleViewController) 12 | } 13 | if let tab = viewController as? UITabBarController { 14 | if let selected = tab.selectedViewController { 15 | return topViewController(selected) 16 | } 17 | } 18 | if let presented = viewController?.presentedViewController { 19 | return topViewController(presented) 20 | } 21 | 22 | return viewController 23 | } 24 | } 25 | 26 | extension UIViewController { 27 | 28 | var alertController: UIAlertController? { 29 | guard let alert = UIApplication.topViewController() as? UIAlertController else { return nil } 30 | return alert 31 | } 32 | } 33 | 34 | extension Array { 35 | 36 | @discardableResult 37 | mutating func append(_ newArray: Array) -> CountableRange { 38 | let range = count..<(count + newArray.count) 39 | self += newArray 40 | return range 41 | } 42 | 43 | @discardableResult 44 | mutating func insert(_ newArray: Array, at index: Int) -> CountableRange { 45 | let mIndex = Swift.max(0, index) 46 | let start = Swift.min(count, mIndex) 47 | let end = start + newArray.count 48 | 49 | let left = self[0.. (_ element: T) { 56 | let anotherSelf = self 57 | 58 | removeAll(keepingCapacity: true) 59 | 60 | anotherSelf.each { (index: Int, current: Element) in 61 | if (current as! T) !== element { 62 | self.append(current) 63 | } 64 | } 65 | } 66 | 67 | func each(_ exe: (Int, Element) -> ()) { 68 | for (index, item) in enumerated() { 69 | exe(index, item) 70 | } 71 | } 72 | } 73 | 74 | extension UIColor { 75 | 76 | /// SwifterSwift: https://github.com/SwifterSwift/SwifterSwift 77 | /// Hexadecimal value string (read-only). 78 | public var hexString: String { 79 | let components: [Int] = { 80 | let c = cgColor.components! 81 | let components = c.count == 4 ? c : [c[0], c[0], c[0], c[1]] 82 | return components.map { Int($0 * 255.0) } 83 | }() 84 | return String(format: "#%02X%02X%02X", components[0], components[1], components[2]) 85 | } 86 | 87 | /// SwifterSwift: https://github.com/SwifterSwift/SwifterSwift 88 | /// Short hexadecimal value string (read-only, if applicable). 89 | public var shortHexString: String? { 90 | let string = hexString.replacingOccurrences(of: "#", with: "") 91 | let chrs = Array(string) 92 | guard chrs[0] == chrs[1], chrs[2] == chrs[3], chrs[4] == chrs[5] else { return nil } 93 | return "#\(chrs[0])\(chrs[2])\(chrs[4])" 94 | } 95 | 96 | /// Color to Image 97 | func toImage(size: CGSize = CGSize(width: 1, height: 1)) -> UIImage { 98 | let rect:CGRect = CGRect(origin: .zero, size: size) 99 | UIGraphicsBeginImageContextWithOptions(rect.size, true, 0) 100 | self.setFill() 101 | UIRectFill(rect) 102 | let image = UIGraphicsGetImageFromCurrentImageContext() 103 | UIGraphicsEndImageContext() 104 | return image! // was image 105 | } 106 | 107 | /// SwifterSwift: https://github.com/SwifterSwift/SwifterSwift 108 | /// RGB components for a Color (between 0 and 255). 109 | /// 110 | /// UIColor.red.rgbComponents.red -> 255 111 | /// UIColor.green.rgbComponents.green -> 255 112 | /// UIColor.blue.rgbComponents.blue -> 255 113 | /// 114 | public var rgbComponents: (red: Int, green: Int, blue: Int) { 115 | var components: [CGFloat] { 116 | let c = cgColor.components! 117 | if c.count == 4 { 118 | return c 119 | } 120 | return [c[0], c[0], c[0], c[1]] 121 | } 122 | let r = components[0] 123 | let g = components[1] 124 | let b = components[2] 125 | return (red: Int(r * 255.0), green: Int(g * 255.0), blue: Int(b * 255.0)) 126 | } 127 | 128 | /// SwifterSwift: https://github.com/SwifterSwift/SwifterSwift 129 | /// RGB components for a Color represented as CGFloat numbers (between 0 and 1) 130 | /// 131 | /// UIColor.red.rgbComponents.red -> 1.0 132 | /// UIColor.green.rgbComponents.green -> 1.0 133 | /// UIColor.blue.rgbComponents.blue -> 1.0 134 | /// 135 | public var cgFloatComponents: (red: CGFloat, green: CGFloat, blue: CGFloat) { 136 | var components: [CGFloat] { 137 | let c = cgColor.components! 138 | if c.count == 4 { 139 | return c 140 | } 141 | return [c[0], c[0], c[0], c[1]] 142 | } 143 | let r = components[0] 144 | let g = components[1] 145 | let b = components[2] 146 | return (red: r, green: g, blue: b) 147 | } 148 | 149 | /// SwifterSwift: https://github.com/SwifterSwift/SwifterSwift 150 | /// Get components of hue, saturation, and brightness, and alpha (read-only). 151 | public var hsbaComponents: (hue: CGFloat, saturation: CGFloat, brightness: CGFloat, alpha: CGFloat) { 152 | var h: CGFloat = 0.0 153 | var s: CGFloat = 0.0 154 | var b: CGFloat = 0.0 155 | var a: CGFloat = 0.0 156 | 157 | self.getHue(&h, saturation: &s, brightness: &b, alpha: &a) 158 | return (hue: h, saturation: s, brightness: b, alpha: a) 159 | } 160 | 161 | /// Random color. 162 | public static var random: UIColor { 163 | let r = Int(arc4random_uniform(255)) 164 | let g = Int(arc4random_uniform(255)) 165 | let b = Int(arc4random_uniform(255)) 166 | return UIColor(red: r, green: g, blue: b) 167 | } 168 | } 169 | 170 | // MARK: - Initializers 171 | public extension UIColor { 172 | 173 | convenience init(hex: Int, alpha: CGFloat) { 174 | let r = CGFloat((hex & 0xFF0000) >> 16)/255 175 | let g = CGFloat((hex & 0xFF00) >> 8)/255 176 | let b = CGFloat(hex & 0xFF)/255 177 | self.init(red: r, green: g, blue: b, alpha: alpha) 178 | } 179 | 180 | convenience init(hex: Int) { 181 | self.init(hex: hex, alpha: 1.0) 182 | } 183 | 184 | /** 185 | Creates an UIColor from HEX String in "#363636" format 186 | 187 | - parameter hexString: HEX String in "#363636" format 188 | - returns: UIColor from HexString 189 | */ 190 | convenience init(hexString: String) { 191 | 192 | let hexString: String = (hexString as NSString).trimmingCharacters(in: .whitespacesAndNewlines) 193 | let scanner = Scanner(string: hexString as String) 194 | 195 | if hexString.hasPrefix("#") { 196 | scanner.currentIndex = scanner.string.startIndex 197 | } 198 | var color: UInt64 = 0 199 | scanner.scanHexInt64(&color) 200 | 201 | let mask = 0x000000FF 202 | let r = Int(color >> 16) & mask 203 | let g = Int(color >> 8) & mask 204 | let b = Int(color) & mask 205 | 206 | let red = CGFloat(r) / 255.0 207 | let green = CGFloat(g) / 255.0 208 | let blue = CGFloat(b) / 255.0 209 | self.init(red:red, green:green, blue:blue, alpha:1) 210 | } 211 | 212 | /// Create UIColor from RGB values with optional transparency. 213 | /// 214 | /// - Parameters: 215 | /// - red: red component. 216 | /// - green: green component. 217 | /// - blue: blue component. 218 | /// - transparency: optional transparency value (default is 1) 219 | convenience init(red: Int, green: Int, blue: Int, transparency: CGFloat = 1) { 220 | assert(red >= 0 && red <= 255, "Invalid red component") 221 | assert(green >= 0 && green <= 255, "Invalid green component") 222 | assert(blue >= 0 && blue <= 255, "Invalid blue component") 223 | var trans: CGFloat { 224 | if transparency > 1 { 225 | return 1 226 | } else if transparency < 0 { 227 | return 0 228 | } else { 229 | return transparency 230 | } 231 | } 232 | self.init(red: CGFloat(red) / 255.0, green: CGFloat(green) / 255.0, blue: CGFloat(blue) / 255.0, alpha: trans) 233 | } 234 | } 235 | 236 | // MARK: - Properties 237 | public extension UIView { 238 | 239 | /// Size of view. 240 | var size: CGSize { 241 | get { 242 | return self.frame.size 243 | } 244 | set { 245 | self.width = newValue.width 246 | self.height = newValue.height 247 | } 248 | } 249 | 250 | /// Width of view. 251 | var width: CGFloat { 252 | get { 253 | return self.frame.size.width 254 | } 255 | set { 256 | self.frame.size.width = newValue 257 | } 258 | } 259 | 260 | /// Height of view. 261 | var height: CGFloat { 262 | get { 263 | return self.frame.size.height 264 | } 265 | set { 266 | self.frame.size.height = newValue 267 | } 268 | } 269 | } 270 | 271 | @IBDesignable 272 | extension UIView { 273 | 274 | @IBInspectable 275 | /// Should the corner be as circle 276 | public var circleCorner: Bool { 277 | get { 278 | return min(bounds.size.height, bounds.size.width) / 2 == cornerRadius 279 | } 280 | set { 281 | cornerRadius = newValue ? min(bounds.size.height, bounds.size.width) / 2 : cornerRadius 282 | } 283 | } 284 | 285 | @IBInspectable 286 | /// Corner radius of view; also inspectable from Storyboard. 287 | public var cornerRadius: CGFloat { 288 | get { 289 | return layer.cornerRadius 290 | } 291 | set { 292 | layer.cornerRadius = circleCorner ? min(bounds.size.height, bounds.size.width) / 2 : newValue 293 | //abs(CGFloat(Int(newValue * 100)) / 100) 294 | } 295 | } 296 | 297 | @IBInspectable 298 | /// Border color of view; also inspectable from Storyboard. 299 | public var borderColor: UIColor? { 300 | get { 301 | guard let color = layer.borderColor else { 302 | return nil 303 | } 304 | return UIColor(cgColor: color) 305 | } 306 | set { 307 | guard let color = newValue else { 308 | layer.borderColor = nil 309 | return 310 | } 311 | layer.borderColor = color.cgColor 312 | } 313 | } 314 | 315 | @IBInspectable 316 | /// Border width of view; also inspectable from Storyboard. 317 | public var borderWidth: CGFloat { 318 | get { 319 | return layer.borderWidth 320 | } 321 | set { 322 | layer.borderWidth = newValue 323 | } 324 | } 325 | 326 | @IBInspectable 327 | /// Shadow color of view; also inspectable from Storyboard. 328 | public var shadowColor: UIColor? { 329 | get { 330 | guard let color = layer.shadowColor else { 331 | return nil 332 | } 333 | return UIColor(cgColor: color) 334 | } 335 | set { 336 | layer.shadowColor = newValue?.cgColor 337 | } 338 | } 339 | 340 | @IBInspectable 341 | /// Shadow offset of view; also inspectable from Storyboard. 342 | public var shadowOffset: CGSize { 343 | get { 344 | return layer.shadowOffset 345 | } 346 | set { 347 | layer.shadowOffset = newValue 348 | } 349 | } 350 | 351 | @IBInspectable 352 | /// Shadow opacity of view; also inspectable from Storyboard. 353 | public var shadowOpacity: Double { 354 | get { 355 | return Double(layer.shadowOpacity) 356 | } 357 | set { 358 | layer.shadowOpacity = Float(newValue) 359 | } 360 | } 361 | 362 | @IBInspectable 363 | /// Shadow radius of view; also inspectable from Storyboard. 364 | public var shadowRadius: CGFloat { 365 | get { 366 | return layer.shadowRadius 367 | } 368 | set { 369 | layer.shadowRadius = newValue 370 | } 371 | } 372 | 373 | @IBInspectable 374 | /// Shadow path of view; also inspectable from Storyboard. 375 | public var shadowPath: CGPath? { 376 | get { 377 | return layer.shadowPath 378 | } 379 | set { 380 | layer.shadowPath = newValue 381 | } 382 | } 383 | 384 | @IBInspectable 385 | /// Should shadow rasterize of view; also inspectable from Storyboard. 386 | /// cache the rendered shadow so that it doesn't need to be redrawn 387 | public var shadowShouldRasterize: Bool { 388 | get { 389 | return layer.shouldRasterize 390 | } 391 | set { 392 | layer.shouldRasterize = newValue 393 | } 394 | } 395 | 396 | @IBInspectable 397 | /// Should shadow rasterize of view; also inspectable from Storyboard. 398 | /// cache the rendered shadow so that it doesn't need to be redrawn 399 | public var shadowRasterizationScale: CGFloat { 400 | get { 401 | return layer.rasterizationScale 402 | } 403 | set { 404 | layer.rasterizationScale = newValue 405 | } 406 | } 407 | 408 | @IBInspectable 409 | /// Corner radius of view; also inspectable from Storyboard. 410 | public var maskToBounds: Bool { 411 | get { 412 | return layer.masksToBounds 413 | } 414 | set { 415 | layer.masksToBounds = newValue 416 | } 417 | } 418 | } 419 | 420 | 421 | final class CurrencyTableViewCell: UITableViewCell { 422 | 423 | static let identifier = String(describing: CurrencyTableViewCell.self) 424 | 425 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 426 | super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) 427 | selectionStyle = .none 428 | backgroundColor = nil 429 | contentView.backgroundColor = nil 430 | } 431 | 432 | required init?(coder aDecoder: NSCoder) { 433 | fatalError("init(coder:) has not been implemented") 434 | } 435 | 436 | override func layoutSubviews() { 437 | super.layoutSubviews() 438 | } 439 | 440 | override func setSelected(_ selected: Bool, animated: Bool) { 441 | super.setSelected(selected, animated: animated) 442 | accessoryType = selected ? .checkmark : .none 443 | } 444 | } 445 | 446 | class ItemWithImage: UICollectionViewCell { 447 | 448 | static let identifier = String(describing: CurrencyTableViewCell.self) 449 | 450 | lazy var imageView: UIImageView = { 451 | $0.backgroundColor = .clear 452 | $0.contentMode = .scaleAspectFill 453 | $0.maskToBounds = true 454 | return $0 455 | }(UIImageView()) 456 | 457 | lazy var unselectedCircle: UIView = { 458 | $0.backgroundColor = .clear 459 | $0.borderWidth = 2 460 | $0.borderColor = .white 461 | $0.maskToBounds = false 462 | return $0 463 | }(UIView()) 464 | 465 | lazy var selectedCircle: UIView = { 466 | $0.backgroundColor = .clear 467 | $0.borderWidth = 2 468 | $0.borderColor = .white 469 | $0.maskToBounds = false 470 | return $0 471 | }(UIView()) 472 | 473 | lazy var selectedPoint: UIView = { 474 | $0.backgroundColor = UIColor(hex: 0x007AFF) 475 | return $0 476 | }(UIView()) 477 | 478 | fileprivate let inset: CGFloat = 8 479 | 480 | public required init?(coder aDecoder: NSCoder) { 481 | super.init(coder: aDecoder) 482 | setup() 483 | } 484 | 485 | public override init(frame: CGRect) { 486 | super.init(frame: frame) 487 | setup() 488 | } 489 | 490 | fileprivate func setup() { 491 | backgroundColor = .clear 492 | 493 | let unselected: UIView = UIView() 494 | unselected.addSubview(imageView) 495 | unselected.addSubview(unselectedCircle) 496 | backgroundView = unselected 497 | 498 | let selected: UIView = UIView() 499 | selected.addSubview(selectedCircle) 500 | selected.addSubview(selectedPoint) 501 | selectedBackgroundView = selected 502 | } 503 | 504 | override public func layoutSubviews() { 505 | super.layoutSubviews() 506 | layout() 507 | } 508 | 509 | func layout() { 510 | imageView.frame = contentView.frame 511 | updateAppearance(forCircle: unselectedCircle) 512 | updateAppearance(forCircle: selectedCircle) 513 | updateAppearance(forPoint: selectedPoint) 514 | } 515 | 516 | override func sizeThatFits(_ size: CGSize) -> CGSize { 517 | contentView.size = size 518 | layout() 519 | return size 520 | } 521 | 522 | func updateAppearance(forCircle view: UIView) { 523 | view.frame.size = CGSize(width: 28, height: 28) 524 | view.frame.origin.x = imageView.bounds.width - unselectedCircle.bounds.width - inset 525 | view.frame.origin.y = inset 526 | view.circleCorner = true 527 | view.shadowColor = UIColor.black.withAlphaComponent(0.4) 528 | view.shadowOffset = .zero 529 | view.shadowRadius = 4 530 | view.shadowOpacity = 0.2 531 | view.shadowPath = UIBezierPath(roundedRect: unselectedCircle.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: unselectedCircle.bounds.width / 2, height: unselectedCircle.bounds.width / 2)).cgPath 532 | view.shadowShouldRasterize = true 533 | view.shadowRasterizationScale = UIScreen.main.scale 534 | } 535 | 536 | func updateAppearance(forPoint view: UIView) { 537 | view.frame.size = CGSize(width: unselectedCircle.width - unselectedCircle.borderWidth * 2, height: unselectedCircle.height - unselectedCircle.borderWidth * 2) 538 | view.center = selectedCircle.center 539 | view.circleCorner = true 540 | } 541 | } 542 | 543 | extension UIView { 544 | 545 | func searchVisualEffectsSubview() -> UIVisualEffectView? { 546 | if let visualEffectView = self as? UIVisualEffectView { 547 | return visualEffectView 548 | } else { 549 | for subview in subviews { 550 | if let found = subview.searchVisualEffectsSubview() { 551 | return found 552 | } 553 | } 554 | } 555 | return nil 556 | } 557 | 558 | /// This is the function to get subViews of a view of a particular type 559 | /// https://stackoverflow.com/a/45297466/5321670 560 | func subViews(type : T.Type) -> [T]{ 561 | var all = [T]() 562 | for view in self.subviews { 563 | if let aView = view as? T{ 564 | all.append(aView) 565 | } 566 | } 567 | return all 568 | } 569 | 570 | 571 | /// This is a function to get subViews of a particular type from view recursively. It would look recursively in all subviews and return back the subviews of the type T 572 | /// https://stackoverflow.com/a/45297466/5321670 573 | func allSubViewsOf(type : T.Type) -> [T]{ 574 | var all = [T]() 575 | func getSubview(view: UIView) { 576 | if let aView = view as? T{ 577 | all.append(aView) 578 | } 579 | guard view.subviews.count>0 else { return } 580 | view.subviews.forEach{ getSubview(view: $0) } 581 | } 582 | getSubview(view: self) 583 | return all 584 | } 585 | } 586 | 587 | // MARK: - Initializers 588 | extension UIAlertController { 589 | 590 | /// Create new alert view controller. 591 | /// 592 | /// - Parameters: 593 | /// - style: alert controller's style. 594 | /// - title: alert controller's title. 595 | /// - message: alert controller's message (default is nil). 596 | /// - defaultActionButtonTitle: default action button title (default is "OK") 597 | /// - tintColor: alert controller's tint color (default is nil) 598 | convenience init(style: UIAlertController.Style, source: UIView? = nil, title: String? = nil, message: String? = nil, tintColor: UIColor? = nil) { 599 | self.init(title: title, message: message, preferredStyle: style) 600 | 601 | // TODO: for iPad or other views 602 | let isPad: Bool = UIDevice.current.userInterfaceIdiom == .pad 603 | let root = UIApplication.shared.windows.first?.rootViewController?.view 604 | 605 | //self.responds(to: #selector(getter: popoverPresentationController)) 606 | if let source = source { 607 | print("----- source") 608 | popoverPresentationController?.sourceView = source 609 | popoverPresentationController?.sourceRect = source.bounds 610 | } else if isPad, let source = root, style == .actionSheet { 611 | print("----- is pad") 612 | popoverPresentationController?.sourceView = source 613 | popoverPresentationController?.sourceRect = CGRect(x: source.bounds.midX, y: source.bounds.midY, width: 0, height: 0) 614 | //popoverPresentationController?.permittedArrowDirections = .down 615 | popoverPresentationController?.permittedArrowDirections = .init(rawValue: 0) 616 | } 617 | 618 | if let color = tintColor { 619 | self.view.tintColor = color 620 | } 621 | } 622 | } 623 | 624 | 625 | // MARK: - Methods 626 | extension UIAlertController { 627 | 628 | /// Present alert view controller in the current view controller. 629 | /// 630 | /// - Parameters: 631 | /// - animated: set true to animate presentation of alert controller (default is true). 632 | /// - vibrate: set true to vibrate the device while presenting the alert (default is false). 633 | /// - completion: an optional completion handler to be called after presenting alert controller (default is nil). 634 | public func show(animated: Bool = true, vibrate: Bool = false, style: UIBlurEffect.Style? = nil, completion: (() -> Void)? = nil) { 635 | 636 | /// TODO: change UIBlurEffectStyle 637 | if let style = style { 638 | for subview in view.allSubViewsOf(type: UIVisualEffectView.self) { 639 | subview.effect = UIBlurEffect(style: style) 640 | } 641 | } 642 | 643 | DispatchQueue.main.async { 644 | UIApplication.shared.windows.first?.rootViewController?.present(self, animated: animated, completion: completion) 645 | if vibrate { 646 | AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) 647 | } 648 | } 649 | } 650 | 651 | /// Add an action to Alert 652 | /// 653 | /// - Parameters: 654 | /// - title: action title 655 | /// - style: action style (default is UIAlertActionStyle.default) 656 | /// - isEnabled: isEnabled status for action (default is true) 657 | /// - handler: optional action handler to be called when button is tapped (default is nil) 658 | func addAction(image: UIImage? = nil, title: String, color: UIColor? = nil, style: UIAlertAction.Style = .default, isEnabled: Bool = true, handler: ((UIAlertAction) -> Void)? = nil) { 659 | //let isPad: Bool = UIDevice.current.userInterfaceIdiom == .pad 660 | //let action = UIAlertAction(title: title, style: isPad && style == .cancel ? .default : style, handler: handler) 661 | let action = UIAlertAction(title: title, style: style, handler: handler) 662 | action.isEnabled = isEnabled 663 | 664 | // button image 665 | if let image = image { 666 | action.setValue(image, forKey: "image") 667 | } 668 | 669 | // button title color 670 | if let color = color { 671 | action.setValue(color, forKey: "titleTextColor") 672 | } 673 | 674 | addAction(action) 675 | } 676 | 677 | /// Set alert's title, font and color 678 | /// 679 | /// - Parameters: 680 | /// - title: alert title 681 | /// - font: alert title font 682 | /// - color: alert title color 683 | func set(title: String?, font: UIFont, color: UIColor) { 684 | if title != nil { 685 | self.title = title 686 | } 687 | setTitle(font: font, color: color) 688 | } 689 | 690 | func setTitle(font: UIFont, color: UIColor) { 691 | guard let title = self.title else { return } 692 | let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] 693 | let attributedTitle = NSMutableAttributedString(string: title, attributes: attributes) 694 | setValue(attributedTitle, forKey: "attributedTitle") 695 | } 696 | 697 | /// Set alert's message, font and color 698 | /// 699 | /// - Parameters: 700 | /// - message: alert message 701 | /// - font: alert message font 702 | /// - color: alert message color 703 | func set(message: String?, font: UIFont, color: UIColor) { 704 | if message != nil { 705 | self.message = message 706 | } 707 | setMessage(font: font, color: color) 708 | } 709 | 710 | func setMessage(font: UIFont, color: UIColor) { 711 | guard let message = self.message else { return } 712 | let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] 713 | let attributedMessage = NSMutableAttributedString(string: message, attributes: attributes) 714 | setValue(attributedMessage, forKey: "attributedMessage") 715 | } 716 | 717 | /// Set alert's content viewController 718 | /// 719 | /// - Parameters: 720 | /// - vc: ViewController 721 | /// - height: height of content viewController 722 | func set(vc: UIViewController?, width: CGFloat? = nil, height: CGFloat? = nil) { 723 | guard let vc = vc else { return } 724 | setValue(vc, forKey: "contentViewController") 725 | if let height = height { 726 | vc.preferredContentSize.height = height 727 | preferredContentSize.height = height 728 | } 729 | } 730 | } 731 | 732 | 733 | public struct Assets { 734 | 735 | /// Requests access to the user's contacts 736 | /// 737 | /// - Parameter requestGranted: Result as Bool 738 | public static func requestAccess(_ requestGranted: @escaping (PHAuthorizationStatus) -> ()) { 739 | PHPhotoLibrary.requestAuthorization { status in 740 | requestGranted(status) 741 | } 742 | } 743 | 744 | /// Result Enum 745 | /// 746 | /// - Success: Returns Array of PHAsset 747 | /// - Error: Returns error 748 | public enum FetchResults { 749 | case success(response: [PHAsset]) 750 | case error(error: Error) 751 | } 752 | 753 | public static func fetch(_ completion: @escaping (FetchResults) -> Void) { 754 | guard PHPhotoLibrary.authorizationStatus() == .authorized else { 755 | let error: NSError = NSError(domain: "PhotoLibrary Error", code: 1, userInfo: [NSLocalizedDescriptionKey: "No PhotoLibrary Access"]) 756 | completion(FetchResults.error(error: error)) 757 | return 758 | } 759 | 760 | DispatchQueue.global(qos: .userInitiated).async { 761 | let fetchResult = PHAsset.fetchAssets(with: .image, options: PHFetchOptions()) 762 | 763 | if fetchResult.count > 0 { 764 | var assets = [PHAsset]() 765 | fetchResult.enumerateObjects { object, _, _ in 766 | assets.insert(object, at: 0) 767 | } 768 | 769 | DispatchQueue.main.async { 770 | completion(FetchResults.success(response: assets)) 771 | } 772 | } 773 | } 774 | } 775 | 776 | /// Result Enum 777 | /// 778 | /// - Success: Returns UIImage 779 | /// - Error: Returns error 780 | public enum ResolveResult { 781 | case success(response: UIImage?) 782 | case error(error: Error) 783 | } 784 | 785 | public static func resolve(asset: PHAsset, size: CGSize = PHImageManagerMaximumSize, completion: @escaping (_ image: UIImage?) -> Void) { 786 | let imageManager = PHImageManager.default() 787 | 788 | let requestOptions = PHImageRequestOptions() 789 | requestOptions.deliveryMode = .highQualityFormat 790 | requestOptions.resizeMode = .exact 791 | requestOptions.isNetworkAccessAllowed = true 792 | 793 | imageManager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: requestOptions) { image, info in 794 | if let info = info, info["PHImageFileUTIKey"] == nil { 795 | DispatchQueue.main.async { 796 | completion(image) 797 | } 798 | } 799 | } 800 | } 801 | 802 | /// Result Enum 803 | /// 804 | /// - Success: Returns Array of UIImage 805 | /// - Error: Returns error 806 | public enum ResolveResults { 807 | case success(response: [UIImage]) 808 | case error(error: Error) 809 | } 810 | 811 | public static func resolve(assets: [PHAsset], size: CGSize = CGSize(width: 720, height: 1280), completion: @escaping (_ images: [UIImage]) -> Void) -> [UIImage] { 812 | let imageManager = PHImageManager.default() 813 | let requestOptions = PHImageRequestOptions() 814 | requestOptions.isSynchronous = true 815 | 816 | var images = [UIImage]() 817 | for asset in assets { 818 | imageManager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: requestOptions) { image, _ in 819 | if let image = image { 820 | images.append(image) 821 | } 822 | } 823 | } 824 | 825 | DispatchQueue.main.async { 826 | completion(images) 827 | } 828 | 829 | return images 830 | } 831 | } 832 | 833 | extension UIAlertController { 834 | 835 | /// Add PhotoLibrary Picker 836 | /// 837 | /// - Parameters: 838 | /// - flow: scroll direction 839 | /// - pagging: pagging 840 | /// - images: for content to select 841 | /// - selection: type and action for selection of image/images 842 | 843 | func addPhotoLibraryPicker(flow: UICollectionView.ScrollDirection, paging: Bool, selection: PhotoLibraryPickerViewController.Selection) { 844 | let selection: PhotoLibraryPickerViewController.Selection = selection 845 | var asset: PHAsset? 846 | var assets: [PHAsset] = [] 847 | 848 | let buttonAdd = UIAlertAction(title: "Add", style: .default) { action in 849 | switch selection { 850 | 851 | case .single(let action): 852 | action?(asset) 853 | 854 | case .multiple(let action): 855 | action?(assets) 856 | } 857 | } 858 | buttonAdd.isEnabled = false 859 | 860 | let vc = PhotoLibraryPickerViewController(flow: flow, paging: paging, selection: { 861 | switch selection { 862 | case .single(_): 863 | return .single(action: { new in 864 | buttonAdd.isEnabled = new != nil 865 | asset = new 866 | }) 867 | case .multiple(_): 868 | return .multiple(action: { new in 869 | buttonAdd.isEnabled = new.count > 0 870 | assets = new 871 | }) 872 | } 873 | }()) 874 | 875 | if UIDevice.current.userInterfaceIdiom == .pad { 876 | vc.preferredContentSize.height = vc.preferredSize.height * 0.9 877 | vc.preferredContentSize.width = vc.preferredSize.width * 0.9 878 | } else { 879 | vc.preferredContentSize.height = vc.preferredSize.height 880 | } 881 | 882 | addAction(buttonAdd) 883 | set(vc: vc) 884 | } 885 | } 886 | 887 | final class PhotoLibraryPickerViewController: UIViewController { 888 | 889 | public typealias SingleSelection = (PHAsset?) -> Swift.Void 890 | public typealias MultipleSelection = ([PHAsset]) -> Swift.Void 891 | 892 | public enum Selection { 893 | case single(action: SingleSelection?) 894 | case multiple(action: MultipleSelection?) 895 | } 896 | 897 | // MARK: UI Metrics 898 | 899 | var preferredSize: CGSize { 900 | return UIScreen.main.bounds.size 901 | } 902 | 903 | var columns: CGFloat { 904 | switch layout.scrollDirection { 905 | case .vertical: return UIDevice.current.userInterfaceIdiom == .pad ? 3 : 2 906 | case .horizontal: return 1 907 | default: 908 | return UIDevice.current.userInterfaceIdiom == .pad ? 3 : 2 909 | } 910 | } 911 | 912 | var itemSize: CGSize { 913 | switch layout.scrollDirection { 914 | case .vertical: 915 | return CGSize(width: view.bounds.width / columns, height: view.bounds.width / columns) 916 | case .horizontal: 917 | return CGSize(width: view.bounds.width, height: view.bounds.height / columns) 918 | default: 919 | return CGSize(width: view.bounds.width / columns, height: view.bounds.width / columns) 920 | } 921 | } 922 | 923 | // MARK: Properties 924 | 925 | fileprivate lazy var collectionView: UICollectionView = { [unowned self] in 926 | $0.dataSource = self 927 | $0.delegate = self 928 | $0.register(ItemWithImage.self, forCellWithReuseIdentifier: String(describing: ItemWithImage.self)) 929 | $0.showsVerticalScrollIndicator = false 930 | $0.showsHorizontalScrollIndicator = false 931 | $0.decelerationRate = UIScrollView.DecelerationRate.fast 932 | $0.contentInsetAdjustmentBehavior = .always 933 | $0.bounces = true 934 | $0.backgroundColor = .clear 935 | $0.maskToBounds = false 936 | $0.clipsToBounds = false 937 | return $0 938 | }(UICollectionView(frame: .zero, collectionViewLayout: layout)) 939 | 940 | fileprivate lazy var layout: UICollectionViewFlowLayout = { 941 | $0.minimumInteritemSpacing = 0 942 | $0.minimumLineSpacing = 0 943 | $0.sectionInset = .zero 944 | return $0 945 | }(UICollectionViewFlowLayout()) 946 | 947 | fileprivate var selection: Selection? 948 | fileprivate var assets: [PHAsset] = [] 949 | fileprivate var selectedAssets: [PHAsset] = [] 950 | 951 | // MARK: Initialize 952 | 953 | required public init(flow: UICollectionView.ScrollDirection, paging: Bool, selection: Selection) { 954 | super.init(nibName: nil, bundle: nil) 955 | 956 | self.selection = selection 957 | self.layout.scrollDirection = flow 958 | 959 | self.collectionView.isPagingEnabled = paging 960 | 961 | switch selection { 962 | 963 | case .single(_): 964 | collectionView.allowsSelection = true 965 | case .multiple(_): 966 | collectionView.allowsMultipleSelection = true 967 | } 968 | } 969 | 970 | required init?(coder aDecoder: NSCoder) { 971 | fatalError("init(coder:) has not been implemented") 972 | } 973 | 974 | deinit { 975 | print("has deinitialized") 976 | } 977 | 978 | override func loadView() { 979 | view = collectionView 980 | } 981 | 982 | override func viewDidLoad() { 983 | super.viewDidLoad() 984 | updatePhotos() 985 | } 986 | 987 | func updatePhotos() { 988 | checkStatus { [unowned self] assets in 989 | self.assets.removeAll() 990 | self.assets.append(contentsOf: assets) 991 | self.collectionView.reloadData() 992 | } 993 | } 994 | 995 | func checkStatus(completionHandler: @escaping ([PHAsset]) -> ()) { 996 | switch PHPhotoLibrary.authorizationStatus() { 997 | 998 | case .notDetermined: 999 | /// This case means the user is prompted for the first time for allowing contacts 1000 | Assets.requestAccess { [unowned self] status in 1001 | self.checkStatus(completionHandler: completionHandler) 1002 | } 1003 | 1004 | case .authorized: 1005 | /// Authorization granted by user for this app. 1006 | DispatchQueue.main.async { 1007 | self.fetchPhotos(completionHandler: completionHandler) 1008 | } 1009 | 1010 | case .denied, .restricted: 1011 | /// User has denied the current app to access the contacts. 1012 | let productName = Bundle.main.infoDictionary!["CFBundleName"]! 1013 | let alert = UIAlertController(style: .alert, title: "Permission denied", message: "\(productName) does not have access to contacts. Please, allow the application to access to your photo library.") 1014 | alert.addAction(title: "Settings", style: .destructive) { action in 1015 | if let settingsURL = URL(string: UIApplication.openSettingsURLString) { 1016 | UIApplication.shared.open(settingsURL) 1017 | } 1018 | } 1019 | alert.addAction(title: "OK", style: .cancel) { [unowned self] action in 1020 | self.alertController?.dismiss(animated: true) 1021 | } 1022 | alert.show() 1023 | default: 1024 | break 1025 | } 1026 | } 1027 | 1028 | func fetchPhotos(completionHandler: @escaping ([PHAsset]) -> ()) { 1029 | Assets.fetch { [unowned self] result in 1030 | switch result { 1031 | 1032 | case .success(let assets): 1033 | completionHandler(assets) 1034 | 1035 | case .error(let error): 1036 | let alert = UIAlertController(style: .alert, title: "Error", message: error.localizedDescription) 1037 | alert.addAction(title: "OK") { [unowned self] action in 1038 | self.alertController?.dismiss(animated: true) 1039 | } 1040 | alert.show() 1041 | } 1042 | } 1043 | } 1044 | } 1045 | 1046 | // MARK: - CollectionViewDelegate 1047 | 1048 | extension PhotoLibraryPickerViewController: UICollectionViewDelegate { 1049 | 1050 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 1051 | let asset = assets[indexPath.item] 1052 | switch selection { 1053 | 1054 | case .single(let action)?: 1055 | action?(asset) 1056 | 1057 | case .multiple(let action)?: 1058 | selectedAssets.contains(asset) 1059 | ? selectedAssets.remove(asset) 1060 | : selectedAssets.append(asset) 1061 | action?(selectedAssets) 1062 | 1063 | case .none: break } 1064 | } 1065 | 1066 | func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { 1067 | let asset = assets[indexPath.item] 1068 | switch selection { 1069 | case .multiple(let action)?: 1070 | selectedAssets.contains(asset) 1071 | ? selectedAssets.remove(asset) 1072 | : selectedAssets.append(asset) 1073 | action?(selectedAssets) 1074 | default: break } 1075 | } 1076 | } 1077 | 1078 | // MARK: - CollectionViewDataSource 1079 | 1080 | extension PhotoLibraryPickerViewController: UICollectionViewDataSource { 1081 | 1082 | func numberOfSections(in collectionView: UICollectionView) -> Int { 1083 | return 1 1084 | } 1085 | 1086 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 1087 | return assets.count 1088 | } 1089 | 1090 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 1091 | guard let item = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ItemWithImage.self), for: indexPath) as? ItemWithImage else { return UICollectionViewCell() } 1092 | let asset = assets[indexPath.item] 1093 | Assets.resolve(asset: asset, size: item.bounds.size) { new in 1094 | item.imageView.image = new 1095 | } 1096 | return item 1097 | } 1098 | } 1099 | 1100 | // MARK: - CollectionViewDelegateFlowLayout 1101 | 1102 | extension PhotoLibraryPickerViewController: UICollectionViewDelegateFlowLayout { 1103 | 1104 | public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 1105 | return itemSize 1106 | } 1107 | } 1108 | 1109 | struct ViewControllerWrapper: UIViewControllerRepresentable { 1110 | typealias UIViewControllerType = PhotoLibraryPickerViewController 1111 | @Binding var assets : [PHAsset] 1112 | 1113 | 1114 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> ViewControllerWrapper.UIViewControllerType { 1115 | return PhotoLibraryPickerViewController(flow: .vertical, paging: false, selection: .multiple{ assets in self.assets = assets }) 1116 | } 1117 | 1118 | 1119 | func updateUIViewController(_ uiViewController: ViewControllerWrapper.UIViewControllerType, context: UIViewControllerRepresentableContext) { 1120 | // 1121 | } 1122 | } 1123 | 1124 | 1125 | @available(iOS 13.0, *) 1126 | public struct PhotoLibraryPicker: View { 1127 | @Environment(\.presentationMode) var presentationMode 1128 | @Binding var images : [Picture] 1129 | @State var assets = [PHAsset]() 1130 | 1131 | public init(_ images: Binding<[Picture]>) { 1132 | self._images = images 1133 | } 1134 | 1135 | var saveButton: some View { 1136 | Button(action: { 1137 | self.images = self.assets.map { Picture(asset: $0) } 1138 | self.presentationMode.wrappedValue.dismiss() 1139 | }, label: { Text("Save") }).disabled(assets.isEmpty) 1140 | } 1141 | 1142 | var cancelButton: some View { 1143 | Button(action: { 1144 | self.images = [Picture]() 1145 | self.presentationMode.wrappedValue.dismiss() }, 1146 | label: { Text("Cancel") }) 1147 | } 1148 | 1149 | public var body: some View { 1150 | NavigationView { 1151 | ViewControllerWrapper(assets: $assets) 1152 | .navigationBarTitle(Text("Photos"), displayMode: .inline) 1153 | .navigationBarItems(leading: cancelButton, trailing: saveButton) 1154 | } 1155 | } 1156 | } 1157 | 1158 | @available(iOS 13.0, *) 1159 | public struct Picture : Identifiable { 1160 | public let id = UUID() 1161 | public let asset: PHAsset 1162 | 1163 | public func toImage(width: Int = 100, height: Int = 100, mode: PHImageContentMode = .aspectFit) -> Image { 1164 | let manager = PHImageManager.default() 1165 | let option = PHImageRequestOptions() 1166 | var image = UIImage() 1167 | option.isSynchronous = true 1168 | manager.requestImage(for: asset, targetSize: CGSize(width: width, height: height), contentMode: mode, options: option, resultHandler: {(result, info)->Void in 1169 | image = result! 1170 | }) 1171 | return Image(uiImage: image) 1172 | } 1173 | } 1174 | 1175 | struct PhotoLibraryPicker_Previews: PreviewProvider { 1176 | static var previews: some View { 1177 | PhotoLibraryPicker(.constant([Picture]())) 1178 | } 1179 | } 1180 | 1181 | 1182 | --------------------------------------------------------------------------------