├── .gitignore ├── MVC.playground ├── Contents.swift └── contents.xcplayground ├── MVP.playground ├── Contents.swift └── contents.xcplayground ├── MVVM ├── MVVM.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── MVVM │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ ├── Model │ └── Photo.swift │ ├── Service │ ├── APIService.swift │ └── content.json │ └── View │ ├── PhotoDetailView │ └── PhotoDetailViewController.swift │ ├── PhotoListTableView │ ├── PhotoListViewController.swift │ └── PhotoListViewModel.swift │ └── PhotoTableViewCell │ ├── PhotoListCellViewModel.swift │ └── PhotoListTableViewCell.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/xcode,swift,macos 3 | 4 | ### macOS ### 5 | *.DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | 9 | # Icon must end with two \r 10 | Icon 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | .com.apple.timemachine.donotpresent 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | 31 | ### Swift ### 32 | # Xcode 33 | # 34 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 35 | 36 | ## Build generated 37 | build/ 38 | DerivedData/ 39 | 40 | ## Various settings 41 | *.pbxuser 42 | !default.pbxuser 43 | *.mode1v3 44 | !default.mode1v3 45 | *.mode2v3 46 | !default.mode2v3 47 | *.perspectivev3 48 | !default.perspectivev3 49 | xcuserdata/ 50 | 51 | ## Other 52 | *.moved-aside 53 | *.xccheckout 54 | *.xcscmblueprint 55 | 56 | ## Obj-C/Swift specific 57 | *.hmap 58 | *.ipa 59 | *.dSYM.zip 60 | *.dSYM 61 | 62 | ## Playgrounds 63 | timeline.xctimeline 64 | playground.xcworkspace 65 | 66 | # Swift Package Manager 67 | # 68 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 69 | # Packages/ 70 | # Package.pins 71 | .build/ 72 | 73 | # CocoaPods - Refactored to standalone file 74 | 75 | # Carthage - Refactored to standalone file 76 | 77 | # fastlane 78 | # 79 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 80 | # screenshots whenever they are needed. 81 | # For more information about the recommended setup visit: 82 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 83 | 84 | fastlane/report.xml 85 | fastlane/Preview.html 86 | fastlane/screenshots 87 | fastlane/test_output 88 | 89 | ### Xcode ### 90 | # Xcode 91 | # 92 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 93 | 94 | ## Build generated 95 | 96 | ## Various settings 97 | 98 | ## Other 99 | 100 | ### Xcode Patch ### 101 | *.xcodeproj/* 102 | !*.xcodeproj/project.pbxproj 103 | !*.xcodeproj/xcshareddata/ 104 | !*.xcworkspace/contents.xcworkspacedata 105 | /*.gcno 106 | 107 | 108 | # End of https://www.gitignore.io/api/xcode,swift,macos 109 | MVVM/Podfile 110 | MVVM/Podfile.lock 111 | MVVM/MVVM.xcworkspace/contents.xcworkspacedata 112 | MVVM/MVVM.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist 113 | MVVM/Pods/Manifest.lock 114 | MVVM/Pods/Pods.xcodeproj/project.pbxproj 115 | MVVM/Pods/SDWebImage/LICENSE 116 | MVVM/Pods/SDWebImage/README.md 117 | MVVM/Pods/SDWebImage/SDWebImage/NSButton+WebCache.h 118 | MVVM/Pods/SDWebImage/SDWebImage/NSButton+WebCache.m 119 | MVVM/Pods/SDWebImage/SDWebImage/NSData+ImageContentType.h 120 | MVVM/Pods/SDWebImage/SDWebImage/NSData+ImageContentType.m 121 | MVVM/Pods/SDWebImage/SDWebImage/NSImage+WebCache.h 122 | MVVM/Pods/SDWebImage/SDWebImage/NSImage+WebCache.m 123 | MVVM/Pods/SDWebImage/SDWebImage/SDAnimatedImageRep.h 124 | MVVM/Pods/SDWebImage/SDWebImage/SDAnimatedImageRep.m 125 | MVVM/Pods/SDWebImage/SDWebImage/SDImageCache.h 126 | MVVM/Pods/SDWebImage/SDWebImage/SDImageCache.m 127 | MVVM/Pods/SDWebImage/SDWebImage/SDImageCacheConfig.h 128 | MVVM/Pods/SDWebImage/SDWebImage/SDImageCacheConfig.m 129 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageCoder.h 130 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageCoder.m 131 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageCoderHelper.h 132 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageCoderHelper.m 133 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageCodersManager.h 134 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageCodersManager.m 135 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageCompat.h 136 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageCompat.m 137 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageDownloader.h 138 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageDownloader.m 139 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageDownloaderOperation.h 140 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageDownloaderOperation.m 141 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageFrame.h 142 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageFrame.m 143 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageGIFCoder.h 144 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageGIFCoder.m 145 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageImageIOCoder.h 146 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageImageIOCoder.m 147 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageManager.h 148 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageManager.m 149 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageOperation.h 150 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImagePrefetcher.h 151 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImagePrefetcher.m 152 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageTransition.h 153 | MVVM/Pods/SDWebImage/SDWebImage/SDWebImageTransition.m 154 | MVVM/Pods/SDWebImage/SDWebImage/UIButton+WebCache.h 155 | MVVM/Pods/SDWebImage/SDWebImage/UIButton+WebCache.m 156 | MVVM/Pods/SDWebImage/SDWebImage/UIImage+ForceDecode.h 157 | MVVM/Pods/SDWebImage/SDWebImage/UIImage+ForceDecode.m 158 | MVVM/Pods/SDWebImage/SDWebImage/UIImage+GIF.h 159 | MVVM/Pods/SDWebImage/SDWebImage/UIImage+GIF.m 160 | MVVM/Pods/SDWebImage/SDWebImage/UIImage+MultiFormat.h 161 | MVVM/Pods/SDWebImage/SDWebImage/UIImage+MultiFormat.m 162 | MVVM/Pods/SDWebImage/SDWebImage/UIImageView+HighlightedWebCache.h 163 | MVVM/Pods/SDWebImage/SDWebImage/UIImageView+HighlightedWebCache.m 164 | MVVM/Pods/SDWebImage/SDWebImage/UIImageView+WebCache.h 165 | MVVM/Pods/SDWebImage/SDWebImage/UIImageView+WebCache.m 166 | MVVM/Pods/SDWebImage/SDWebImage/UIView+WebCache.h 167 | MVVM/Pods/SDWebImage/SDWebImage/UIView+WebCache.m 168 | MVVM/Pods/SDWebImage/SDWebImage/UIView+WebCacheOperation.h 169 | MVVM/Pods/SDWebImage/SDWebImage/UIView+WebCacheOperation.m 170 | MVVM/Pods/Target Support Files/Pods-MVVM/Info.plist 171 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM-acknowledgements.markdown 172 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM-acknowledgements.plist 173 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM-dummy.m 174 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM-frameworks.sh 175 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM-resources.sh 176 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM-umbrella.h 177 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM.debug.xcconfig 178 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM.modulemap 179 | MVVM/Pods/Target Support Files/Pods-MVVM/Pods-MVVM.release.xcconfig 180 | MVVM/Pods/Target Support Files/SDWebImage/Info.plist 181 | MVVM/Pods/Target Support Files/SDWebImage/SDWebImage-dummy.m 182 | MVVM/Pods/Target Support Files/SDWebImage/SDWebImage-prefix.pch 183 | MVVM/Pods/Target Support Files/SDWebImage/SDWebImage-umbrella.h 184 | MVVM/Pods/Target Support Files/SDWebImage/SDWebImage.modulemap 185 | MVVM/Pods/Target Support Files/SDWebImage/SDWebImage.xcconfig 186 | -------------------------------------------------------------------------------- /MVC.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | struct Person { // Model 5 | let firstName:String 6 | let lastName:String 7 | } 8 | 9 | class GreetingViewController: UIViewController { // Controller 10 | 11 | var person:Person! 12 | 13 | // Views are belong to Controller => tightly COUPLED 14 | lazy var showGreetingButton: UIButton = { 15 | let button = UIButton() 16 | button.setTitle("Click me", for: .normal) 17 | button.setTitle("You badass", for: .highlighted) 18 | button.setTitleColor(UIColor.white, for: .normal) 19 | button.setTitleColor(UIColor.red, for: .highlighted) 20 | button.addTarget(self, action: #selector(didTapButton(sender:)), for: .touchUpInside) 21 | button.translatesAutoresizingMaskIntoConstraints = false 22 | return button 23 | }() 24 | 25 | var greetingLabel: UILabel = { 26 | let label = UILabel() 27 | label.textColor = UIColor.white 28 | label.textAlignment = .center 29 | label.translatesAutoresizingMaskIntoConstraints = false 30 | return label 31 | }() 32 | 33 | override func viewDidLoad() { 34 | super.viewDidLoad() 35 | self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480) 36 | self.setupLayout() 37 | } 38 | 39 | // Layout codes in Controller 40 | func setupLayout() { 41 | self.setupButton() 42 | self.setupLabel() 43 | } 44 | 45 | private func setupButton() { 46 | self.view.addSubview(showGreetingButton) 47 | showGreetingButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true 48 | showGreetingButton.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true 49 | } 50 | 51 | private func setupLabel() { 52 | self.view.addSubview(greetingLabel) 53 | greetingLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true 54 | greetingLabel.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -30).isActive = true 55 | } 56 | 57 | @objc func didTapButton(sender: UIButton) { // Update View 58 | self.greetingLabel.text = "Hello " + self.person.firstName + " " + self.person.lastName 59 | } 60 | } 61 | 62 | let model = Person(firstName: "Wasin", lastName: "Thonkaew") 63 | let vc = GreetingViewController() 64 | vc.person = model 65 | 66 | PlaygroundPage.current.liveView = vc.view 67 | -------------------------------------------------------------------------------- /MVC.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /MVP.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | //: A UIKit based Playground for presenting user interface 2 | 3 | import UIKit 4 | import PlaygroundSupport 5 | 6 | struct Person { // Model 7 | let firstName:String 8 | let lastName:String 9 | } 10 | 11 | protocol GreetingView:class { 12 | func setGreeting(greeting:String) 13 | } 14 | 15 | protocol GreetingViewPresenter { 16 | init(view: GreetingView, person: Person) 17 | func showGreeting() 18 | } 19 | 20 | class GreetingPresenter : GreetingViewPresenter { 21 | weak var view: GreetingView? 22 | let person: Person 23 | 24 | required init(view: GreetingView, person: Person) { 25 | self.view = view 26 | self.person = person 27 | } 28 | func showGreeting() { // Update View 29 | let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName 30 | self.view?.setGreeting(greeting: greeting) 31 | } 32 | } 33 | 34 | class GreetingViewController : UIViewController, GreetingView { 35 | var presenter: GreetingViewPresenter! 36 | 37 | lazy var showGreetingButton: UIButton = { 38 | let button = UIButton() 39 | button.setTitle("Click me", for: .normal) 40 | button.setTitle("You badass", for: .highlighted) 41 | button.setTitleColor(UIColor.white, for: .normal) 42 | button.setTitleColor(UIColor.red, for: .highlighted) 43 | button.translatesAutoresizingMaskIntoConstraints = false 44 | return button 45 | }() 46 | 47 | var greetingLabel: UILabel = { 48 | let label = UILabel() 49 | label.textColor = UIColor.white 50 | label.textAlignment = .center 51 | label.translatesAutoresizingMaskIntoConstraints = false 52 | return label 53 | }() 54 | 55 | override func viewDidLoad() { 56 | super.viewDidLoad() 57 | self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480) 58 | setupLayout() 59 | self.showGreetingButton.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) 60 | } 61 | 62 | func setupLayout() { 63 | self.setupButton() 64 | self.setupLabel() 65 | } 66 | 67 | private func setupButton() { 68 | self.view.addSubview(showGreetingButton) 69 | showGreetingButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true 70 | showGreetingButton.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true 71 | } 72 | 73 | private func setupLabel() { 74 | self.view.addSubview(greetingLabel) 75 | greetingLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true 76 | greetingLabel.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -30).isActive = true 77 | } 78 | 79 | @objc func didTapButton(button: UIButton) { 80 | self.presenter.showGreeting() // Send Action to Presenter 81 | } 82 | 83 | func setGreeting(greeting: String) { 84 | self.greetingLabel.text = greeting 85 | } 86 | // layout code goes here 87 | } 88 | // Present the view controller in the Live View window 89 | 90 | let model = Person(firstName: "Wasin", lastName: "Thonkaew") 91 | let view = GreetingViewController() 92 | let presenter = GreetingPresenter(view: view, person: model) 93 | view.presenter = presenter 94 | 95 | PlaygroundPage.current.liveView = view 96 | -------------------------------------------------------------------------------- /MVP.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /MVVM/MVVM.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 0DCFAE142A041029C149DA45 /* Pods_MVVM.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FCD1A9F0C3A60F8E9B0C5B3 /* Pods_MVVM.framework */; }; 11 | 3ED1030620E5F8B200A86B08 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED1030520E5F8B200A86B08 /* AppDelegate.swift */; }; 12 | 3ED1030820E5F8B200A86B08 /* PhotoListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED1030720E5F8B200A86B08 /* PhotoListViewController.swift */; }; 13 | 3ED1030B20E5F8B200A86B08 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3ED1030920E5F8B200A86B08 /* Main.storyboard */; }; 14 | 3ED1030D20E5F8B400A86B08 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3ED1030C20E5F8B400A86B08 /* Assets.xcassets */; }; 15 | 3ED1031020E5F8B400A86B08 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3ED1030E20E5F8B400A86B08 /* LaunchScreen.storyboard */; }; 16 | 3ED1031920E5FB2E00A86B08 /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED1031820E5FB2E00A86B08 /* Photo.swift */; }; 17 | 3ED1031C20E5FBF800A86B08 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED1031B20E5FBF800A86B08 /* APIService.swift */; }; 18 | 3ED1031E20E606BF00A86B08 /* content.json in Resources */ = {isa = PBXBuildFile; fileRef = 3ED1031D20E606BF00A86B08 /* content.json */; }; 19 | 3ED1032120E609F300A86B08 /* PhotoListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED1032020E609F300A86B08 /* PhotoListTableViewCell.swift */; }; 20 | 3ED1032320E60D8600A86B08 /* PhotoListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED1032220E60D8600A86B08 /* PhotoListViewModel.swift */; }; 21 | 3ED1032520E60DD200A86B08 /* PhotoListCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED1032420E60DD200A86B08 /* PhotoListCellViewModel.swift */; }; 22 | 3EFA487320E6666A0021EFFF /* PhotoDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EFA487220E6666A0021EFFF /* PhotoDetailViewController.swift */; }; 23 | /* End PBXBuildFile section */ 24 | 25 | /* Begin PBXFileReference section */ 26 | 3ED1030220E5F8B200A86B08 /* MVVM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MVVM.app; sourceTree = BUILT_PRODUCTS_DIR; }; 27 | 3ED1030520E5F8B200A86B08 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 28 | 3ED1030720E5F8B200A86B08 /* PhotoListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PhotoListViewController.swift; path = "/Users/dongkun/Programming/Swift/Programming Guide/Design Pattern/MVVM/MVVM/View/PhotoListTableView/PhotoListViewController.swift"; sourceTree = ""; }; 29 | 3ED1030A20E5F8B200A86B08 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 30 | 3ED1030C20E5F8B400A86B08 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 31 | 3ED1030F20E5F8B400A86B08 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 32 | 3ED1031120E5F8B400A86B08 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 33 | 3ED1031820E5FB2E00A86B08 /* Photo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Photo.swift; sourceTree = ""; }; 34 | 3ED1031B20E5FBF800A86B08 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 35 | 3ED1031D20E606BF00A86B08 /* content.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = content.json; sourceTree = ""; }; 36 | 3ED1032020E609F300A86B08 /* PhotoListTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PhotoListTableViewCell.swift; path = "/Users/dongkun/Programming/Swift/Programming Guide/Design Pattern/MVVM/MVVM/View/PhotoTableViewCell/PhotoListTableViewCell.swift"; sourceTree = ""; }; 37 | 3ED1032220E60D8600A86B08 /* PhotoListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoListViewModel.swift; sourceTree = ""; }; 38 | 3ED1032420E60DD200A86B08 /* PhotoListCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoListCellViewModel.swift; sourceTree = ""; }; 39 | 3EFA487220E6666A0021EFFF /* PhotoDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoDetailViewController.swift; sourceTree = ""; }; 40 | 3F4247298D7F10913D1AA3FA /* Pods-MVVM.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MVVM.debug.xcconfig"; path = "Pods/Target Support Files/Pods-MVVM/Pods-MVVM.debug.xcconfig"; sourceTree = ""; }; 41 | 7FCD1A9F0C3A60F8E9B0C5B3 /* Pods_MVVM.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MVVM.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 42 | A8F8CF19B39A9D994D21E289 /* Pods-MVVM.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MVVM.release.xcconfig"; path = "Pods/Target Support Files/Pods-MVVM/Pods-MVVM.release.xcconfig"; sourceTree = ""; }; 43 | /* End PBXFileReference section */ 44 | 45 | /* Begin PBXFrameworksBuildPhase section */ 46 | 3ED102FF20E5F8B200A86B08 /* Frameworks */ = { 47 | isa = PBXFrameworksBuildPhase; 48 | buildActionMask = 2147483647; 49 | files = ( 50 | 0DCFAE142A041029C149DA45 /* Pods_MVVM.framework in Frameworks */, 51 | ); 52 | runOnlyForDeploymentPostprocessing = 0; 53 | }; 54 | /* End PBXFrameworksBuildPhase section */ 55 | 56 | /* Begin PBXGroup section */ 57 | 3ED102F920E5F8B200A86B08 = { 58 | isa = PBXGroup; 59 | children = ( 60 | 3ED1030420E5F8B200A86B08 /* MVVM */, 61 | 3ED1030320E5F8B200A86B08 /* Products */, 62 | F94127E56AFB6EB59D072981 /* Pods */, 63 | E1BB4E919B349540DB938BCC /* Frameworks */, 64 | ); 65 | sourceTree = ""; 66 | }; 67 | 3ED1030320E5F8B200A86B08 /* Products */ = { 68 | isa = PBXGroup; 69 | children = ( 70 | 3ED1030220E5F8B200A86B08 /* MVVM.app */, 71 | ); 72 | name = Products; 73 | sourceTree = ""; 74 | }; 75 | 3ED1030420E5F8B200A86B08 /* MVVM */ = { 76 | isa = PBXGroup; 77 | children = ( 78 | 3ED1031F20E609D400A86B08 /* View */, 79 | 3ED1031A20E5FBEA00A86B08 /* Service */, 80 | 3ED1031720E5FB2700A86B08 /* Model */, 81 | 3ED1030520E5F8B200A86B08 /* AppDelegate.swift */, 82 | 3ED1030920E5F8B200A86B08 /* Main.storyboard */, 83 | 3ED1030C20E5F8B400A86B08 /* Assets.xcassets */, 84 | 3ED1030E20E5F8B400A86B08 /* LaunchScreen.storyboard */, 85 | 3ED1031120E5F8B400A86B08 /* Info.plist */, 86 | ); 87 | path = MVVM; 88 | sourceTree = ""; 89 | }; 90 | 3ED1031720E5FB2700A86B08 /* Model */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | 3ED1031820E5FB2E00A86B08 /* Photo.swift */, 94 | ); 95 | path = Model; 96 | sourceTree = ""; 97 | }; 98 | 3ED1031A20E5FBEA00A86B08 /* Service */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | 3ED1031B20E5FBF800A86B08 /* APIService.swift */, 102 | 3ED1031D20E606BF00A86B08 /* content.json */, 103 | ); 104 | path = Service; 105 | sourceTree = ""; 106 | }; 107 | 3ED1031F20E609D400A86B08 /* View */ = { 108 | isa = PBXGroup; 109 | children = ( 110 | 3EFA487120E6664A0021EFFF /* PhotoDetailView */, 111 | 3EFA487020E660580021EFFF /* PhotoTableViewCell */, 112 | 3EFA486F20E65FA40021EFFF /* PhotoListTableView */, 113 | ); 114 | path = View; 115 | sourceTree = ""; 116 | }; 117 | 3EFA486F20E65FA40021EFFF /* PhotoListTableView */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | 3ED1030720E5F8B200A86B08 /* PhotoListViewController.swift */, 121 | 3ED1032220E60D8600A86B08 /* PhotoListViewModel.swift */, 122 | ); 123 | path = PhotoListTableView; 124 | sourceTree = ""; 125 | }; 126 | 3EFA487020E660580021EFFF /* PhotoTableViewCell */ = { 127 | isa = PBXGroup; 128 | children = ( 129 | 3ED1032020E609F300A86B08 /* PhotoListTableViewCell.swift */, 130 | 3ED1032420E60DD200A86B08 /* PhotoListCellViewModel.swift */, 131 | ); 132 | path = PhotoTableViewCell; 133 | sourceTree = ""; 134 | }; 135 | 3EFA487120E6664A0021EFFF /* PhotoDetailView */ = { 136 | isa = PBXGroup; 137 | children = ( 138 | 3EFA487220E6666A0021EFFF /* PhotoDetailViewController.swift */, 139 | ); 140 | path = PhotoDetailView; 141 | sourceTree = ""; 142 | }; 143 | E1BB4E919B349540DB938BCC /* Frameworks */ = { 144 | isa = PBXGroup; 145 | children = ( 146 | 7FCD1A9F0C3A60F8E9B0C5B3 /* Pods_MVVM.framework */, 147 | ); 148 | name = Frameworks; 149 | sourceTree = ""; 150 | }; 151 | F94127E56AFB6EB59D072981 /* Pods */ = { 152 | isa = PBXGroup; 153 | children = ( 154 | 3F4247298D7F10913D1AA3FA /* Pods-MVVM.debug.xcconfig */, 155 | A8F8CF19B39A9D994D21E289 /* Pods-MVVM.release.xcconfig */, 156 | ); 157 | name = Pods; 158 | sourceTree = ""; 159 | }; 160 | /* End PBXGroup section */ 161 | 162 | /* Begin PBXNativeTarget section */ 163 | 3ED1030120E5F8B200A86B08 /* MVVM */ = { 164 | isa = PBXNativeTarget; 165 | buildConfigurationList = 3ED1031420E5F8B400A86B08 /* Build configuration list for PBXNativeTarget "MVVM" */; 166 | buildPhases = ( 167 | 5204B419D48B08428349A5B3 /* [CP] Check Pods Manifest.lock */, 168 | 3ED102FE20E5F8B200A86B08 /* Sources */, 169 | 3ED102FF20E5F8B200A86B08 /* Frameworks */, 170 | 3ED1030020E5F8B200A86B08 /* Resources */, 171 | CDF329E2FB5DC63C4F14FF61 /* [CP] Embed Pods Frameworks */, 172 | ); 173 | buildRules = ( 174 | ); 175 | dependencies = ( 176 | ); 177 | name = MVVM; 178 | productName = MVVM; 179 | productReference = 3ED1030220E5F8B200A86B08 /* MVVM.app */; 180 | productType = "com.apple.product-type.application"; 181 | }; 182 | /* End PBXNativeTarget section */ 183 | 184 | /* Begin PBXProject section */ 185 | 3ED102FA20E5F8B200A86B08 /* Project object */ = { 186 | isa = PBXProject; 187 | attributes = { 188 | LastSwiftUpdateCheck = 1000; 189 | LastUpgradeCheck = 1000; 190 | ORGANIZATIONNAME = "이동건"; 191 | TargetAttributes = { 192 | 3ED1030120E5F8B200A86B08 = { 193 | CreatedOnToolsVersion = 10.0; 194 | }; 195 | }; 196 | }; 197 | buildConfigurationList = 3ED102FD20E5F8B200A86B08 /* Build configuration list for PBXProject "MVVM" */; 198 | compatibilityVersion = "Xcode 9.3"; 199 | developmentRegion = en; 200 | hasScannedForEncodings = 0; 201 | knownRegions = ( 202 | en, 203 | Base, 204 | ); 205 | mainGroup = 3ED102F920E5F8B200A86B08; 206 | productRefGroup = 3ED1030320E5F8B200A86B08 /* Products */; 207 | projectDirPath = ""; 208 | projectRoot = ""; 209 | targets = ( 210 | 3ED1030120E5F8B200A86B08 /* MVVM */, 211 | ); 212 | }; 213 | /* End PBXProject section */ 214 | 215 | /* Begin PBXResourcesBuildPhase section */ 216 | 3ED1030020E5F8B200A86B08 /* Resources */ = { 217 | isa = PBXResourcesBuildPhase; 218 | buildActionMask = 2147483647; 219 | files = ( 220 | 3ED1031020E5F8B400A86B08 /* LaunchScreen.storyboard in Resources */, 221 | 3ED1030D20E5F8B400A86B08 /* Assets.xcassets in Resources */, 222 | 3ED1030B20E5F8B200A86B08 /* Main.storyboard in Resources */, 223 | 3ED1031E20E606BF00A86B08 /* content.json in Resources */, 224 | ); 225 | runOnlyForDeploymentPostprocessing = 0; 226 | }; 227 | /* End PBXResourcesBuildPhase section */ 228 | 229 | /* Begin PBXShellScriptBuildPhase section */ 230 | 5204B419D48B08428349A5B3 /* [CP] Check Pods Manifest.lock */ = { 231 | isa = PBXShellScriptBuildPhase; 232 | buildActionMask = 2147483647; 233 | files = ( 234 | ); 235 | inputPaths = ( 236 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 237 | "${PODS_ROOT}/Manifest.lock", 238 | ); 239 | name = "[CP] Check Pods Manifest.lock"; 240 | outputPaths = ( 241 | "$(DERIVED_FILE_DIR)/Pods-MVVM-checkManifestLockResult.txt", 242 | ); 243 | runOnlyForDeploymentPostprocessing = 0; 244 | shellPath = /bin/sh; 245 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 246 | showEnvVarsInLog = 0; 247 | }; 248 | CDF329E2FB5DC63C4F14FF61 /* [CP] Embed Pods Frameworks */ = { 249 | isa = PBXShellScriptBuildPhase; 250 | buildActionMask = 2147483647; 251 | files = ( 252 | ); 253 | inputPaths = ( 254 | "${SRCROOT}/Pods/Target Support Files/Pods-MVVM/Pods-MVVM-frameworks.sh", 255 | "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", 256 | ); 257 | name = "[CP] Embed Pods Frameworks"; 258 | outputPaths = ( 259 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", 260 | ); 261 | runOnlyForDeploymentPostprocessing = 0; 262 | shellPath = /bin/sh; 263 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-MVVM/Pods-MVVM-frameworks.sh\"\n"; 264 | showEnvVarsInLog = 0; 265 | }; 266 | /* End PBXShellScriptBuildPhase section */ 267 | 268 | /* Begin PBXSourcesBuildPhase section */ 269 | 3ED102FE20E5F8B200A86B08 /* Sources */ = { 270 | isa = PBXSourcesBuildPhase; 271 | buildActionMask = 2147483647; 272 | files = ( 273 | 3ED1030820E5F8B200A86B08 /* PhotoListViewController.swift in Sources */, 274 | 3ED1032320E60D8600A86B08 /* PhotoListViewModel.swift in Sources */, 275 | 3ED1031920E5FB2E00A86B08 /* Photo.swift in Sources */, 276 | 3ED1032520E60DD200A86B08 /* PhotoListCellViewModel.swift in Sources */, 277 | 3ED1031C20E5FBF800A86B08 /* APIService.swift in Sources */, 278 | 3ED1032120E609F300A86B08 /* PhotoListTableViewCell.swift in Sources */, 279 | 3EFA487320E6666A0021EFFF /* PhotoDetailViewController.swift in Sources */, 280 | 3ED1030620E5F8B200A86B08 /* AppDelegate.swift in Sources */, 281 | ); 282 | runOnlyForDeploymentPostprocessing = 0; 283 | }; 284 | /* End PBXSourcesBuildPhase section */ 285 | 286 | /* Begin PBXVariantGroup section */ 287 | 3ED1030920E5F8B200A86B08 /* Main.storyboard */ = { 288 | isa = PBXVariantGroup; 289 | children = ( 290 | 3ED1030A20E5F8B200A86B08 /* Base */, 291 | ); 292 | name = Main.storyboard; 293 | sourceTree = ""; 294 | }; 295 | 3ED1030E20E5F8B400A86B08 /* LaunchScreen.storyboard */ = { 296 | isa = PBXVariantGroup; 297 | children = ( 298 | 3ED1030F20E5F8B400A86B08 /* Base */, 299 | ); 300 | name = LaunchScreen.storyboard; 301 | sourceTree = ""; 302 | }; 303 | /* End PBXVariantGroup section */ 304 | 305 | /* Begin XCBuildConfiguration section */ 306 | 3ED1031220E5F8B400A86B08 /* Debug */ = { 307 | isa = XCBuildConfiguration; 308 | buildSettings = { 309 | ALWAYS_SEARCH_USER_PATHS = NO; 310 | CLANG_ANALYZER_NONNULL = YES; 311 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 312 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 313 | CLANG_CXX_LIBRARY = "libc++"; 314 | CLANG_ENABLE_MODULES = YES; 315 | CLANG_ENABLE_OBJC_ARC = YES; 316 | CLANG_ENABLE_OBJC_WEAK = YES; 317 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 318 | CLANG_WARN_BOOL_CONVERSION = YES; 319 | CLANG_WARN_COMMA = YES; 320 | CLANG_WARN_CONSTANT_CONVERSION = YES; 321 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 322 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 323 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 324 | CLANG_WARN_EMPTY_BODY = YES; 325 | CLANG_WARN_ENUM_CONVERSION = YES; 326 | CLANG_WARN_INFINITE_RECURSION = YES; 327 | CLANG_WARN_INT_CONVERSION = YES; 328 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 329 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 330 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 331 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 332 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 333 | CLANG_WARN_STRICT_PROTOTYPES = YES; 334 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 335 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 336 | CLANG_WARN_UNREACHABLE_CODE = YES; 337 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 338 | CODE_SIGN_IDENTITY = "iPhone Developer"; 339 | COPY_PHASE_STRIP = NO; 340 | DEBUG_INFORMATION_FORMAT = dwarf; 341 | ENABLE_STRICT_OBJC_MSGSEND = YES; 342 | ENABLE_TESTABILITY = YES; 343 | GCC_C_LANGUAGE_STANDARD = gnu11; 344 | GCC_DYNAMIC_NO_PIC = NO; 345 | GCC_NO_COMMON_BLOCKS = YES; 346 | GCC_OPTIMIZATION_LEVEL = 0; 347 | GCC_PREPROCESSOR_DEFINITIONS = ( 348 | "DEBUG=1", 349 | "$(inherited)", 350 | ); 351 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 352 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 353 | GCC_WARN_UNDECLARED_SELECTOR = YES; 354 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 355 | GCC_WARN_UNUSED_FUNCTION = YES; 356 | GCC_WARN_UNUSED_VARIABLE = YES; 357 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 358 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 359 | ONLY_ACTIVE_ARCH = YES; 360 | SDKROOT = iphoneos; 361 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 362 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 363 | }; 364 | name = Debug; 365 | }; 366 | 3ED1031320E5F8B400A86B08 /* Release */ = { 367 | isa = XCBuildConfiguration; 368 | buildSettings = { 369 | ALWAYS_SEARCH_USER_PATHS = NO; 370 | CLANG_ANALYZER_NONNULL = YES; 371 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 372 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 373 | CLANG_CXX_LIBRARY = "libc++"; 374 | CLANG_ENABLE_MODULES = YES; 375 | CLANG_ENABLE_OBJC_ARC = YES; 376 | CLANG_ENABLE_OBJC_WEAK = YES; 377 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 378 | CLANG_WARN_BOOL_CONVERSION = YES; 379 | CLANG_WARN_COMMA = YES; 380 | CLANG_WARN_CONSTANT_CONVERSION = YES; 381 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 382 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 383 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 384 | CLANG_WARN_EMPTY_BODY = YES; 385 | CLANG_WARN_ENUM_CONVERSION = YES; 386 | CLANG_WARN_INFINITE_RECURSION = YES; 387 | CLANG_WARN_INT_CONVERSION = YES; 388 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 389 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 390 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 391 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 392 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 393 | CLANG_WARN_STRICT_PROTOTYPES = YES; 394 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 395 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 396 | CLANG_WARN_UNREACHABLE_CODE = YES; 397 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 398 | CODE_SIGN_IDENTITY = "iPhone Developer"; 399 | COPY_PHASE_STRIP = NO; 400 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 401 | ENABLE_NS_ASSERTIONS = NO; 402 | ENABLE_STRICT_OBJC_MSGSEND = YES; 403 | GCC_C_LANGUAGE_STANDARD = gnu11; 404 | GCC_NO_COMMON_BLOCKS = YES; 405 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 406 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 407 | GCC_WARN_UNDECLARED_SELECTOR = YES; 408 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 409 | GCC_WARN_UNUSED_FUNCTION = YES; 410 | GCC_WARN_UNUSED_VARIABLE = YES; 411 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 412 | MTL_ENABLE_DEBUG_INFO = NO; 413 | SDKROOT = iphoneos; 414 | SWIFT_COMPILATION_MODE = wholemodule; 415 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 416 | VALIDATE_PRODUCT = YES; 417 | }; 418 | name = Release; 419 | }; 420 | 3ED1031520E5F8B400A86B08 /* Debug */ = { 421 | isa = XCBuildConfiguration; 422 | baseConfigurationReference = 3F4247298D7F10913D1AA3FA /* Pods-MVVM.debug.xcconfig */; 423 | buildSettings = { 424 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 425 | CODE_SIGN_STYLE = Automatic; 426 | DEVELOPMENT_TEAM = 452DHHAC9T; 427 | INFOPLIST_FILE = MVVM/Info.plist; 428 | LD_RUNPATH_SEARCH_PATHS = ( 429 | "$(inherited)", 430 | "@executable_path/Frameworks", 431 | ); 432 | PRODUCT_BUNDLE_IDENTIFIER = com.MVVM; 433 | PRODUCT_NAME = "$(TARGET_NAME)"; 434 | SWIFT_VERSION = 4.2; 435 | TARGETED_DEVICE_FAMILY = "1,2"; 436 | }; 437 | name = Debug; 438 | }; 439 | 3ED1031620E5F8B400A86B08 /* Release */ = { 440 | isa = XCBuildConfiguration; 441 | baseConfigurationReference = A8F8CF19B39A9D994D21E289 /* Pods-MVVM.release.xcconfig */; 442 | buildSettings = { 443 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 444 | CODE_SIGN_STYLE = Automatic; 445 | DEVELOPMENT_TEAM = 452DHHAC9T; 446 | INFOPLIST_FILE = MVVM/Info.plist; 447 | LD_RUNPATH_SEARCH_PATHS = ( 448 | "$(inherited)", 449 | "@executable_path/Frameworks", 450 | ); 451 | PRODUCT_BUNDLE_IDENTIFIER = com.MVVM; 452 | PRODUCT_NAME = "$(TARGET_NAME)"; 453 | SWIFT_VERSION = 4.2; 454 | TARGETED_DEVICE_FAMILY = "1,2"; 455 | }; 456 | name = Release; 457 | }; 458 | /* End XCBuildConfiguration section */ 459 | 460 | /* Begin XCConfigurationList section */ 461 | 3ED102FD20E5F8B200A86B08 /* Build configuration list for PBXProject "MVVM" */ = { 462 | isa = XCConfigurationList; 463 | buildConfigurations = ( 464 | 3ED1031220E5F8B400A86B08 /* Debug */, 465 | 3ED1031320E5F8B400A86B08 /* Release */, 466 | ); 467 | defaultConfigurationIsVisible = 0; 468 | defaultConfigurationName = Release; 469 | }; 470 | 3ED1031420E5F8B400A86B08 /* Build configuration list for PBXNativeTarget "MVVM" */ = { 471 | isa = XCConfigurationList; 472 | buildConfigurations = ( 473 | 3ED1031520E5F8B400A86B08 /* Debug */, 474 | 3ED1031620E5F8B400A86B08 /* Release */, 475 | ); 476 | defaultConfigurationIsVisible = 0; 477 | defaultConfigurationName = Release; 478 | }; 479 | /* End XCConfigurationList section */ 480 | }; 481 | rootObject = 3ED102FA20E5F8B200A86B08 /* Project object */; 482 | } 483 | -------------------------------------------------------------------------------- /MVVM/MVVM.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MVVM/MVVM.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MVVM/MVVM/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // MVVM 4 | // 5 | // Created by 이동건 on 29/06/2018. 6 | // Copyright © 2018 이동건. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | // Override point for customization after application launch. 19 | return true 20 | } 21 | 22 | func applicationWillResignActive(_ application: UIApplication) { 23 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 24 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 25 | } 26 | 27 | func applicationDidEnterBackground(_ application: UIApplication) { 28 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 29 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 30 | } 31 | 32 | func applicationWillEnterForeground(_ application: UIApplication) { 33 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 34 | } 35 | 36 | func applicationDidBecomeActive(_ application: UIApplication) { 37 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 38 | } 39 | 40 | func applicationWillTerminate(_ application: UIApplication) { 41 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 42 | } 43 | 44 | 45 | } 46 | 47 | -------------------------------------------------------------------------------- /MVVM/MVVM/Assets.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" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /MVVM/MVVM/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /MVVM/MVVM/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 | -------------------------------------------------------------------------------- /MVVM/MVVM/Base.lproj/Main.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 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 46 | 52 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /MVVM/MVVM/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 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /MVVM/MVVM/Model/Photo.swift: -------------------------------------------------------------------------------- 1 | // Photo.swift 2 | // MVVM 3 | // 4 | // Created by 이동건 on 29/06/2018. 5 | // Copyright © 2018 이동건. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Photos: Codable { 11 | let photos: [Photo] 12 | } 13 | 14 | struct Photo: Codable { 15 | let id: Int 16 | let name: String 17 | let description: String? 18 | let created_at: Date 19 | let image_url: String 20 | let for_sale: Bool 21 | let camera: String? 22 | } 23 | -------------------------------------------------------------------------------- /MVVM/MVVM/Service/APIService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIService.swift 3 | // MVVM 4 | // 5 | // Created by 이동건 on 29/06/2018. 6 | // Copyright © 2018 이동건. All rights reserved. 7 | // 8 | import Foundation 9 | 10 | enum APIError: String, Error { 11 | case noNetwork = "No Network" 12 | case serverOverload = "Server is overloaded" 13 | case permissionDenied = "You don't have permission" 14 | } 15 | 16 | protocol APIServiceProtocol { 17 | func fetchPopularPhoto( complete: @escaping ( _ success: Bool, _ photos: [Photo], _ error: APIError? )->() ) 18 | } 19 | 20 | class APIService: APIServiceProtocol { 21 | // Simulate a long waiting for fetching 22 | func fetchPopularPhoto( complete: @escaping ( _ success: Bool, _ photos: [Photo], _ error: APIError? )->() ) { 23 | DispatchQueue.global().async { 24 | sleep(3) 25 | let path = Bundle.main.path(forResource: "content", ofType: "json")! 26 | let data = try! Data(contentsOf: URL(fileURLWithPath: path)) 27 | let decoder = JSONDecoder() 28 | decoder.dateDecodingStrategy = .iso8601 29 | let photos = try! decoder.decode(Photos.self, from: data) 30 | complete( true, photos.photos, nil ) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /MVVM/MVVM/Service/content.json: -------------------------------------------------------------------------------- 1 | { 2 | "current_page": 1, 3 | "total_pages": 1000, 4 | "total_items": 55697, 5 | "photos": [ 6 | { 7 | "id": 230200335, 8 | "user_id": 5797936, 9 | "name": "vienna.street", 10 | "description": "shooting in vienna", 11 | "camera": null, 12 | "lens": null, 13 | "focal_length": null, 14 | "iso": null, 15 | "shutter_speed": null, 16 | "aperture": null, 17 | "times_viewed": 37, 18 | "rating": 80.4, 19 | "status": 1, 20 | "created_at": "2017-10-01T10:23:31-04:00", 21 | "category": 21, 22 | "location": null, 23 | "latitude": 48.2081743, 24 | "longitude": 16.3738189000001, 25 | "taken_at": null, 26 | "hi_res_uploaded": 0, 27 | "for_sale": true, 28 | "width": 6715, 29 | "height": 3789, 30 | "votes_count": 12, 31 | "favorites_count": 0, 32 | "comments_count": 0, 33 | "nsfw": false, 34 | "sales_count": 0, 35 | "for_sale_date": null, 36 | "highest_rating": 80.4, 37 | "highest_rating_date": "2017-10-01T10:25:52-04:00", 38 | "license_type": 0, 39 | "converted": 0, 40 | "collections_count": 0, 41 | "crop_version": 0, 42 | "privacy": false, 43 | "profile": true, 44 | "for_critique": false, 45 | "critiques_callout_dismissed": false, 46 | "image_url": "https://drscdn.500px.org/photo/230200335/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=87c378f20802362700eba08bf18c5ac001b5fa4a67c90b613b38b7fe7138bd8c", 47 | "images": [ 48 | { 49 | "size": 3, 50 | "url": "https://drscdn.500px.org/photo/230200335/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=87c378f20802362700eba08bf18c5ac001b5fa4a67c90b613b38b7fe7138bd8c", 51 | "https_url": "https://drscdn.500px.org/photo/230200335/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=87c378f20802362700eba08bf18c5ac001b5fa4a67c90b613b38b7fe7138bd8c", 52 | "format": "jpeg" 53 | } 54 | ], 55 | "url": "/photo/230200335/vienna-street-by-w-h", 56 | "positive_votes_count": 12, 57 | "converted_bits": 0, 58 | "store_download": false, 59 | "store_print": false, 60 | "store_license": false, 61 | "request_to_buy_enabled": false, 62 | "license_requests_enabled": true, 63 | "store_width": 6715, 64 | "store_height": 3789, 65 | "voted": false, 66 | "liked": false, 67 | "disliked": false, 68 | "purchased": false, 69 | "watermark": false, 70 | "image_format": "jpeg", 71 | "user": { 72 | "id": 5797936, 73 | "username": "Vienna-Street-Photography", 74 | "firstname": "W", 75 | "lastname": "H", 76 | "city": "Wien", 77 | "country": "Austria", 78 | "usertype": 0, 79 | "fullname": "W H", 80 | "userpic_url": "https://pacdn.500px.org/5797936/de2fb89866d28abafb02158e01d9195dead71c94/1.jpg?20", 81 | "userpic_https_url": "https://pacdn.500px.org/5797936/de2fb89866d28abafb02158e01d9195dead71c94/1.jpg?20", 82 | "cover_url": "https://pacdn.500px.org/5797936/de2fb89866d28abafb02158e01d9195dead71c94/cover_2048.jpg?99", 83 | "upgrade_status": 3, 84 | "store_on": true, 85 | "affection": 352653, 86 | "avatars": { 87 | "default": { 88 | "https": "https://pacdn.500px.org/5797936/de2fb89866d28abafb02158e01d9195dead71c94/1.jpg?20" 89 | }, 90 | "large": { 91 | "https": "https://pacdn.500px.org/5797936/de2fb89866d28abafb02158e01d9195dead71c94/2.jpg?20" 92 | }, 93 | "small": { 94 | "https": "https://pacdn.500px.org/5797936/de2fb89866d28abafb02158e01d9195dead71c94/3.jpg?20" 95 | }, 96 | "tiny": { 97 | "https": "https://pacdn.500px.org/5797936/de2fb89866d28abafb02158e01d9195dead71c94/4.jpg?20" 98 | } 99 | } 100 | }, 101 | "licensing_requested": false, 102 | "licensing_suggested": false, 103 | "is_free_photo": false 104 | }, 105 | { 106 | "id": 230200211, 107 | "user_id": 15582643, 108 | "name": "couple", 109 | "description": null, 110 | "camera": "NIKON D500", 111 | "lens": "200.0-500.0 mm f/5.6", 112 | "focal_length": "460", 113 | "iso": "1000", 114 | "shutter_speed": "1/640", 115 | "aperture": "5.6", 116 | "times_viewed": 23, 117 | "rating": 82.5, 118 | "status": 1, 119 | "created_at": "2017-10-01T10:22:21-04:00", 120 | "category": 11, 121 | "location": null, 122 | "latitude": null, 123 | "longitude": null, 124 | "taken_at": "2017-09-30T13:12:30-04:00", 125 | "hi_res_uploaded": 0, 126 | "for_sale": true, 127 | "width": 4621, 128 | "height": 2542, 129 | "votes_count": 14, 130 | "favorites_count": 0, 131 | "comments_count": 0, 132 | "nsfw": false, 133 | "sales_count": 0, 134 | "for_sale_date": null, 135 | "highest_rating": 82.5, 136 | "highest_rating_date": "2017-10-01T10:25:42-04:00", 137 | "license_type": 0, 138 | "converted": 0, 139 | "collections_count": 0, 140 | "crop_version": 0, 141 | "privacy": false, 142 | "profile": true, 143 | "for_critique": false, 144 | "critiques_callout_dismissed": false, 145 | "image_url": "https://drscdn.500px.org/photo/230200211/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=c796aff929af05b516b157f40c37336212a830d5a61b4aac1167bf77b1be4ef2", 146 | "images": [ 147 | { 148 | "size": 3, 149 | "url": "https://drscdn.500px.org/photo/230200211/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=c796aff929af05b516b157f40c37336212a830d5a61b4aac1167bf77b1be4ef2", 150 | "https_url": "https://drscdn.500px.org/photo/230200211/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=c796aff929af05b516b157f40c37336212a830d5a61b4aac1167bf77b1be4ef2", 151 | "format": "jpeg" 152 | } 153 | ], 154 | "url": "/photo/230200211/couple-by-gpedrazzi", 155 | "positive_votes_count": 14, 156 | "converted_bits": 0, 157 | "store_download": false, 158 | "store_print": false, 159 | "store_license": false, 160 | "request_to_buy_enabled": true, 161 | "license_requests_enabled": true, 162 | "store_width": 4621, 163 | "store_height": 2542, 164 | "voted": false, 165 | "liked": false, 166 | "disliked": false, 167 | "purchased": false, 168 | "watermark": false, 169 | "image_format": "jpeg", 170 | "user": { 171 | "id": 15582643, 172 | "username": "GPedrazzi", 173 | "firstname": "GPedrazzi", 174 | "lastname": "", 175 | "city": "", 176 | "country": "", 177 | "usertype": 0, 178 | "fullname": "GPedrazzi", 179 | "userpic_url": "https://pacdn.500px.org/15582643/f4a6ac299d7892bdc30be5a921be91e9e3146dd4/1.jpg?5", 180 | "userpic_https_url": "https://pacdn.500px.org/15582643/f4a6ac299d7892bdc30be5a921be91e9e3146dd4/1.jpg?5", 181 | "cover_url": "https://pacdn.500px.org/15582643/f4a6ac299d7892bdc30be5a921be91e9e3146dd4/cover_2048.jpg?5", 182 | "upgrade_status": 0, 183 | "store_on": true, 184 | "affection": 18739, 185 | "avatars": { 186 | "default": { 187 | "https": "https://pacdn.500px.org/15582643/f4a6ac299d7892bdc30be5a921be91e9e3146dd4/1.jpg?5" 188 | }, 189 | "large": { 190 | "https": "https://pacdn.500px.org/15582643/f4a6ac299d7892bdc30be5a921be91e9e3146dd4/2.jpg?5" 191 | }, 192 | "small": { 193 | "https": "https://pacdn.500px.org/15582643/f4a6ac299d7892bdc30be5a921be91e9e3146dd4/3.jpg?5" 194 | }, 195 | "tiny": { 196 | "https": "https://pacdn.500px.org/15582643/f4a6ac299d7892bdc30be5a921be91e9e3146dd4/4.jpg?5" 197 | } 198 | } 199 | }, 200 | "licensing_requested": false, 201 | "licensing_suggested": false, 202 | "is_free_photo": false 203 | }, 204 | { 205 | "id": 230200149, 206 | "user_id": 2420465, 207 | "name": "Юля", 208 | "description": "Я на других ресурсах: \nInst | VK | FB | VP", 209 | "camera": "Canon EOS 5D Mark II", 210 | "lens": "85mm", 211 | "focal_length": "85", 212 | "iso": "100", 213 | "shutter_speed": "1/1250", 214 | "aperture": "1.8", 215 | "times_viewed": 30, 216 | "rating": 82.5, 217 | "status": 1, 218 | "created_at": "2017-10-01T10:21:36-04:00", 219 | "category": 7, 220 | "location": null, 221 | "latitude": 56.8389261, 222 | "longitude": 60.6057025, 223 | "taken_at": "2017-08-24T18:22:11-04:00", 224 | "hi_res_uploaded": 0, 225 | "for_sale": true, 226 | "width": 667, 227 | "height": 1000, 228 | "votes_count": 14, 229 | "favorites_count": 0, 230 | "comments_count": 0, 231 | "nsfw": false, 232 | "sales_count": 0, 233 | "for_sale_date": null, 234 | "highest_rating": 82.5, 235 | "highest_rating_date": "2017-10-01T10:25:52-04:00", 236 | "license_type": 0, 237 | "converted": 0, 238 | "collections_count": 2, 239 | "crop_version": 0, 240 | "privacy": false, 241 | "profile": true, 242 | "for_critique": false, 243 | "critiques_callout_dismissed": false, 244 | "image_url": "https://drscdn.500px.org/photo/230200149/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=131588fff224249a1dca3488d619d1a504a8f47dce34113fe3607bb7c86048e9", 245 | "images": [ 246 | { 247 | "size": 3, 248 | "url": "https://drscdn.500px.org/photo/230200149/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=131588fff224249a1dca3488d619d1a504a8f47dce34113fe3607bb7c86048e9", 249 | "https_url": "https://drscdn.500px.org/photo/230200149/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=131588fff224249a1dca3488d619d1a504a8f47dce34113fe3607bb7c86048e9", 250 | "format": "jpeg" 251 | } 252 | ], 253 | "url": "/photo/230200149/%D0%AE%D0%BB%D1%8F-by-pavel-vozmischev", 254 | "positive_votes_count": 14, 255 | "converted_bits": 0, 256 | "store_download": false, 257 | "store_print": false, 258 | "store_license": false, 259 | "request_to_buy_enabled": false, 260 | "license_requests_enabled": false, 261 | "voted": false, 262 | "liked": false, 263 | "disliked": false, 264 | "purchased": false, 265 | "watermark": false, 266 | "image_format": "jpeg", 267 | "user": { 268 | "id": 2420465, 269 | "username": "PavelVozmischev", 270 | "firstname": "Pavel", 271 | "lastname": "Vozmischev", 272 | "city": "Екатеринбург", 273 | "country": "Россия", 274 | "usertype": 0, 275 | "fullname": "Pavel Vozmischev", 276 | "userpic_url": "https://pacdn.500px.org/2420465/bc2920edf3905fa6e9fd4e743ac10f77e93386be/1.jpg?6", 277 | "userpic_https_url": "https://pacdn.500px.org/2420465/bc2920edf3905fa6e9fd4e743ac10f77e93386be/1.jpg?6", 278 | "cover_url": "https://pacdn.500px.org/2420465/bc2920edf3905fa6e9fd4e743ac10f77e93386be/cover_2048.jpg?15", 279 | "upgrade_status": 0, 280 | "store_on": true, 281 | "affection": 128219, 282 | "avatars": { 283 | "default": { 284 | "https": "https://pacdn.500px.org/2420465/bc2920edf3905fa6e9fd4e743ac10f77e93386be/1.jpg?6" 285 | }, 286 | "large": { 287 | "https": "https://pacdn.500px.org/2420465/bc2920edf3905fa6e9fd4e743ac10f77e93386be/2.jpg?6" 288 | }, 289 | "small": { 290 | "https": "https://pacdn.500px.org/2420465/bc2920edf3905fa6e9fd4e743ac10f77e93386be/3.jpg?6" 291 | }, 292 | "tiny": { 293 | "https": "https://pacdn.500px.org/2420465/bc2920edf3905fa6e9fd4e743ac10f77e93386be/4.jpg?6" 294 | } 295 | } 296 | }, 297 | "licensing_requested": false, 298 | "licensing_suggested": false, 299 | "is_free_photo": false 300 | }, 301 | { 302 | "id": 230200075, 303 | "user_id": 2961517, 304 | "name": "632", 305 | "description": null, 306 | "camera": null, 307 | "lens": "GF110mmF2 R LM WR", 308 | "focal_length": "110", 309 | "iso": "100", 310 | "shutter_speed": "1/160", 311 | "aperture": "4", 312 | "times_viewed": 57, 313 | "rating": 86.6, 314 | "status": 1, 315 | "created_at": "2017-10-01T10:21:00-04:00", 316 | "category": 7, 317 | "location": null, 318 | "latitude": 12.046498, 319 | "longitude": 108.4957383, 320 | "taken_at": "2017-09-03T01:04:37-04:00", 321 | "hi_res_uploaded": 0, 322 | "for_sale": false, 323 | "width": 8154, 324 | "height": 6192, 325 | "votes_count": 20, 326 | "favorites_count": 0, 327 | "comments_count": 1, 328 | "nsfw": false, 329 | "sales_count": 0, 330 | "for_sale_date": null, 331 | "highest_rating": 86.6, 332 | "highest_rating_date": "2017-10-01T10:25:46-04:00", 333 | "license_type": 0, 334 | "converted": 0, 335 | "collections_count": 0, 336 | "crop_version": 0, 337 | "privacy": false, 338 | "profile": true, 339 | "for_critique": false, 340 | "critiques_callout_dismissed": false, 341 | "image_url": "https://drscdn.500px.org/photo/230200075/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=70dab705c64748e6be482b8b3425d2bc95415396a6686debdd9a3449c3d8bfbd", 342 | "images": [ 343 | { 344 | "size": 3, 345 | "url": "https://drscdn.500px.org/photo/230200075/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=70dab705c64748e6be482b8b3425d2bc95415396a6686debdd9a3449c3d8bfbd", 346 | "https_url": "https://drscdn.500px.org/photo/230200075/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=70dab705c64748e6be482b8b3425d2bc95415396a6686debdd9a3449c3d8bfbd", 347 | "format": "jpeg" 348 | } 349 | ], 350 | "url": "/photo/230200075/632-by-bao-q-ng", 351 | "positive_votes_count": 20, 352 | "converted_bits": 0, 353 | "store_download": false, 354 | "store_print": false, 355 | "store_license": false, 356 | "request_to_buy_enabled": true, 357 | "license_requests_enabled": true, 358 | "store_width": 8154, 359 | "store_height": 6192, 360 | "voted": false, 361 | "liked": false, 362 | "disliked": false, 363 | "purchased": false, 364 | "watermark": false, 365 | "image_format": "jpeg", 366 | "user": { 367 | "id": 2961517, 368 | "username": "BaoQNg", 369 | "firstname": "Bao Q", 370 | "lastname": "Ng", 371 | "city": "", 372 | "country": "", 373 | "usertype": 0, 374 | "fullname": "Bao Q Ng", 375 | "userpic_url": "https://pacdn.500px.org/2961517/3d346e2e6a7f1a1ddc6fd5fbcea1d391efece2c8/1.jpg?4", 376 | "userpic_https_url": "https://pacdn.500px.org/2961517/3d346e2e6a7f1a1ddc6fd5fbcea1d391efece2c8/1.jpg?4", 377 | "cover_url": "https://pacdn.500px.org/2961517/3d346e2e6a7f1a1ddc6fd5fbcea1d391efece2c8/cover_2048.jpg?1", 378 | "upgrade_status": 2, 379 | "store_on": true, 380 | "affection": 215195, 381 | "avatars": { 382 | "default": { 383 | "https": "https://pacdn.500px.org/2961517/3d346e2e6a7f1a1ddc6fd5fbcea1d391efece2c8/1.jpg?4" 384 | }, 385 | "large": { 386 | "https": "https://pacdn.500px.org/2961517/3d346e2e6a7f1a1ddc6fd5fbcea1d391efece2c8/2.jpg?4" 387 | }, 388 | "small": { 389 | "https": "https://pacdn.500px.org/2961517/3d346e2e6a7f1a1ddc6fd5fbcea1d391efece2c8/3.jpg?4" 390 | }, 391 | "tiny": { 392 | "https": "https://pacdn.500px.org/2961517/3d346e2e6a7f1a1ddc6fd5fbcea1d391efece2c8/4.jpg?4" 393 | } 394 | } 395 | }, 396 | "licensing_requested": false, 397 | "licensing_suggested": false, 398 | "is_free_photo": false 399 | }, 400 | { 401 | "id": 230200041, 402 | "user_id": 20653809, 403 | "name": "单诗涵 国家大剧院 北京", 404 | "description": "", 405 | "camera": "NIKON D810", 406 | "lens": "14.0-24.0 mm f/2.8", 407 | "focal_length": "16", 408 | "iso": "64", 409 | "shutter_speed": "10", 410 | "aperture": "13.0", 411 | "times_viewed": 23, 412 | "rating": 83.3, 413 | "status": 1, 414 | "created_at": "2017-10-01T10:20:40-04:00", 415 | "category": 0, 416 | "location": "", 417 | "latitude": null, 418 | "longitude": null, 419 | "taken_at": null, 420 | "hi_res_uploaded": 0, 421 | "for_sale": false, 422 | "width": 2048, 423 | "height": 1367, 424 | "votes_count": 15, 425 | "favorites_count": 0, 426 | "comments_count": 0, 427 | "nsfw": false, 428 | "sales_count": 0, 429 | "for_sale_date": null, 430 | "highest_rating": 83.3, 431 | "highest_rating_date": "2017-10-01T10:25:43-04:00", 432 | "license_type": 0, 433 | "converted": 0, 434 | "collections_count": 0, 435 | "crop_version": 0, 436 | "privacy": false, 437 | "profile": true, 438 | "for_critique": false, 439 | "critiques_callout_dismissed": false, 440 | "image_url": "https://drscdn.500px.org/photo/230200041/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=78bd445b719b21652a68d675900a79f7c1eef3f90ebaf069d5ef5663c1ca227d", 441 | "images": [ 442 | { 443 | "size": 3, 444 | "url": "https://drscdn.500px.org/photo/230200041/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=78bd445b719b21652a68d675900a79f7c1eef3f90ebaf069d5ef5663c1ca227d", 445 | "https_url": "https://drscdn.500px.org/photo/230200041/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=78bd445b719b21652a68d675900a79f7c1eef3f90ebaf069d5ef5663c1ca227d", 446 | "format": "jpeg" 447 | } 448 | ], 449 | "url": "/photo/230200041/%E5%8D%95%E8%AF%97%E6%B6%B5-%E5%9B%BD%E5%AE%B6%E5%A4%A7%E5%89%A7%E9%99%A2-%E5%8C%97%E4%BA%AC-by-%E5%9C%B0%E8%B4%A8%E5%93%A5", 450 | "positive_votes_count": 15, 451 | "converted_bits": 0, 452 | "store_download": false, 453 | "store_print": false, 454 | "store_license": false, 455 | "request_to_buy_enabled": true, 456 | "license_requests_enabled": false, 457 | "store_width": 2048, 458 | "store_height": 1367, 459 | "voted": false, 460 | "liked": false, 461 | "disliked": false, 462 | "purchased": false, 463 | "watermark": false, 464 | "image_format": "jpeg", 465 | "user": { 466 | "id": 20653809, 467 | "username": "vcg-achillesshan", 468 | "firstname": "地质哥", 469 | "lastname": null, 470 | "city": "成都", 471 | "country": "中国", 472 | "usertype": 0, 473 | "fullname": "地质哥", 474 | "userpic_url": "https://secure.gravatar.com/avatar/fca4f8b9d74c469d1aaa9da740fa2720?s=300&r=g&d=https://pacdn.500px.org/userpic.png", 475 | "userpic_https_url": "https://secure.gravatar.com/avatar/fca4f8b9d74c469d1aaa9da740fa2720?s=300&r=g&d=https://pacdn.500px.org/userpic.png", 476 | "cover_url": null, 477 | "upgrade_status": 0, 478 | "store_on": false, 479 | "affection": 3268, 480 | "avatars": { 481 | "default": { 482 | "https": "https://secure.gravatar.com/avatar/fca4f8b9d74c469d1aaa9da740fa2720?s=300&r=g&d=https://pacdn.500px.org/userpic.png" 483 | }, 484 | "large": { 485 | "https": "https://secure.gravatar.com/avatar/fca4f8b9d74c469d1aaa9da740fa2720?s=100&r=g&d=https://pacdn.500px.org/userpic.png" 486 | }, 487 | "small": { 488 | "https": "https://secure.gravatar.com/avatar/fca4f8b9d74c469d1aaa9da740fa2720?s=50&r=g&d=https://pacdn.500px.org/userpic.png" 489 | }, 490 | "tiny": { 491 | "https": "https://secure.gravatar.com/avatar/fca4f8b9d74c469d1aaa9da740fa2720?s=30&r=g&d=https://pacdn.500px.org/userpic.png" 492 | } 493 | } 494 | }, 495 | "licensing_requested": false, 496 | "licensing_suggested": false, 497 | "is_free_photo": false 498 | }, 499 | { 500 | "id": 230200027, 501 | "user_id": 23535937, 502 | "name": "锦绣中华壮美山川之二", 503 | "description": "", 504 | "camera": "NIKON D800E", 505 | "lens": "24.0-70.0 mm f/2.8", 506 | "focal_length": "48", 507 | "iso": "200", 508 | "shutter_speed": "1/800", 509 | "aperture": "6.3", 510 | "times_viewed": 23, 511 | "rating": 83.3, 512 | "status": 1, 513 | "created_at": "2017-10-01T10:20:26-04:00", 514 | "category": 8, 515 | "location": "", 516 | "latitude": null, 517 | "longitude": null, 518 | "taken_at": null, 519 | "hi_res_uploaded": 0, 520 | "for_sale": false, 521 | "width": 2048, 522 | "height": 1359, 523 | "votes_count": 15, 524 | "favorites_count": 0, 525 | "comments_count": 0, 526 | "nsfw": false, 527 | "sales_count": 0, 528 | "for_sale_date": null, 529 | "highest_rating": 83.3, 530 | "highest_rating_date": "2017-10-01T10:25:50-04:00", 531 | "license_type": 0, 532 | "converted": 0, 533 | "collections_count": 0, 534 | "crop_version": 0, 535 | "privacy": false, 536 | "profile": true, 537 | "for_critique": false, 538 | "critiques_callout_dismissed": false, 539 | "image_url": "https://drscdn.500px.org/photo/230200027/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=509209b7b5c52cfcf6d485916ec51773ae25b7a73618476c48cc1456b830e836", 540 | "images": [ 541 | { 542 | "size": 3, 543 | "url": "https://drscdn.500px.org/photo/230200027/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=509209b7b5c52cfcf6d485916ec51773ae25b7a73618476c48cc1456b830e836", 544 | "https_url": "https://drscdn.500px.org/photo/230200027/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=509209b7b5c52cfcf6d485916ec51773ae25b7a73618476c48cc1456b830e836", 545 | "format": "jpeg" 546 | } 547 | ], 548 | "url": "/photo/230200027/%E9%94%A6%E7%BB%A3%E4%B8%AD%E5%8D%8E%E5%A3%AE%E7%BE%8E%E5%B1%B1%E5%B7%9D%E4%B9%8B%E4%BA%8C-by-%E8%A1%8C%E6%91%84%E5%8C%86%E5%8C%86", 549 | "positive_votes_count": 15, 550 | "converted_bits": 0, 551 | "store_download": false, 552 | "store_print": false, 553 | "store_license": false, 554 | "request_to_buy_enabled": true, 555 | "license_requests_enabled": false, 556 | "store_width": 2048, 557 | "store_height": 1359, 558 | "voted": false, 559 | "liked": false, 560 | "disliked": false, 561 | "purchased": false, 562 | "watermark": false, 563 | "image_format": "jpeg", 564 | "user": { 565 | "id": 23535937, 566 | "username": "8cdcad5b04b6aaa07c7cad74e50f45028", 567 | "firstname": "行摄匆匆", 568 | "lastname": null, 569 | "city": "", 570 | "country": "", 571 | "usertype": 12, 572 | "fullname": "行摄匆匆", 573 | "userpic_url": "https://pacdn.500px.org/23535937/bfe8715c1783f631271ca2f5b4cc0f7647edeee7/1.jpg?3", 574 | "userpic_https_url": "https://pacdn.500px.org/23535937/bfe8715c1783f631271ca2f5b4cc0f7647edeee7/1.jpg?3", 575 | "cover_url": "https://pacdn.500px.org/23535937/bfe8715c1783f631271ca2f5b4cc0f7647edeee7/cover_2048.jpg?4", 576 | "upgrade_status": 0, 577 | "store_on": false, 578 | "affection": 1469, 579 | "avatars": { 580 | "default": { 581 | "https": "https://pacdn.500px.org/23535937/bfe8715c1783f631271ca2f5b4cc0f7647edeee7/1.jpg?3" 582 | }, 583 | "large": { 584 | "https": "https://pacdn.500px.org/23535937/bfe8715c1783f631271ca2f5b4cc0f7647edeee7/2.jpg?3" 585 | }, 586 | "small": { 587 | "https": "https://pacdn.500px.org/23535937/bfe8715c1783f631271ca2f5b4cc0f7647edeee7/3.jpg?3" 588 | }, 589 | "tiny": { 590 | "https": "https://pacdn.500px.org/23535937/bfe8715c1783f631271ca2f5b4cc0f7647edeee7/4.jpg?3" 591 | } 592 | } 593 | }, 594 | "licensing_requested": false, 595 | "licensing_suggested": false, 596 | "is_free_photo": false 597 | }, 598 | { 599 | "id": 230200025, 600 | "user_id": 23739783, 601 | "name": "Untitled", 602 | "description": "", 603 | "camera": null, 604 | "lens": null, 605 | "focal_length": null, 606 | "iso": null, 607 | "shutter_speed": null, 608 | "aperture": null, 609 | "times_viewed": 24, 610 | "rating": 82.5, 611 | "status": 1, 612 | "created_at": "2017-10-01T10:20:25-04:00", 613 | "category": 8, 614 | "location": null, 615 | "latitude": 0, 616 | "longitude": 0, 617 | "taken_at": null, 618 | "hi_res_uploaded": 0, 619 | "for_sale": false, 620 | "width": 2048, 621 | "height": 1365, 622 | "votes_count": 14, 623 | "favorites_count": 0, 624 | "comments_count": 1, 625 | "nsfw": false, 626 | "sales_count": 0, 627 | "for_sale_date": null, 628 | "highest_rating": 82.5, 629 | "highest_rating_date": "2017-10-01T10:25:51-04:00", 630 | "license_type": 0, 631 | "converted": 0, 632 | "collections_count": 0, 633 | "crop_version": 0, 634 | "privacy": false, 635 | "profile": true, 636 | "for_critique": false, 637 | "critiques_callout_dismissed": false, 638 | "image_url": "https://drscdn.500px.org/photo/230200025/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=d0e8cd59481ba94424726347be528401ea6a46a5ae480371a1fba71ee6c8d937", 639 | "images": [ 640 | { 641 | "size": 3, 642 | "url": "https://drscdn.500px.org/photo/230200025/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=d0e8cd59481ba94424726347be528401ea6a46a5ae480371a1fba71ee6c8d937", 643 | "https_url": "https://drscdn.500px.org/photo/230200025/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=d0e8cd59481ba94424726347be528401ea6a46a5ae480371a1fba71ee6c8d937", 644 | "format": "jpeg" 645 | } 646 | ], 647 | "url": "/photo/230200025/untitled-by-manilyn-ritchie", 648 | "positive_votes_count": 14, 649 | "converted_bits": 0, 650 | "store_download": false, 651 | "store_print": false, 652 | "store_license": false, 653 | "request_to_buy_enabled": true, 654 | "license_requests_enabled": false, 655 | "store_width": 2048, 656 | "store_height": 1365, 657 | "voted": false, 658 | "liked": false, 659 | "disliked": false, 660 | "purchased": false, 661 | "watermark": false, 662 | "image_format": "jpeg", 663 | "user": { 664 | "id": 23739783, 665 | "username": "laineritchie21", 666 | "firstname": "Manilyn ", 667 | "lastname": "Ritchie ", 668 | "city": "Aberdeen ", 669 | "country": "United Kingdom ", 670 | "usertype": 0, 671 | "fullname": "Manilyn Ritchie", 672 | "userpic_url": "https://secure.gravatar.com/avatar/b73749beff99acc9ee04619f4e48ce9f?s=300&r=g&d=https://pacdn.500px.org/userpic.png", 673 | "userpic_https_url": "https://secure.gravatar.com/avatar/b73749beff99acc9ee04619f4e48ce9f?s=300&r=g&d=https://pacdn.500px.org/userpic.png", 674 | "cover_url": null, 675 | "upgrade_status": 0, 676 | "store_on": false, 677 | "affection": 0, 678 | "avatars": { 679 | "default": { 680 | "https": "https://secure.gravatar.com/avatar/b73749beff99acc9ee04619f4e48ce9f?s=300&r=g&d=https://pacdn.500px.org/userpic.png" 681 | }, 682 | "large": { 683 | "https": "https://secure.gravatar.com/avatar/b73749beff99acc9ee04619f4e48ce9f?s=100&r=g&d=https://pacdn.500px.org/userpic.png" 684 | }, 685 | "small": { 686 | "https": "https://secure.gravatar.com/avatar/b73749beff99acc9ee04619f4e48ce9f?s=50&r=g&d=https://pacdn.500px.org/userpic.png" 687 | }, 688 | "tiny": { 689 | "https": "https://secure.gravatar.com/avatar/b73749beff99acc9ee04619f4e48ce9f?s=30&r=g&d=https://pacdn.500px.org/userpic.png" 690 | } 691 | }, 692 | "following": false 693 | }, 694 | "licensing_requested": false, 695 | "licensing_suggested": false, 696 | "is_free_photo": false 697 | }, 698 | { 699 | "id": 230199989, 700 | "user_id": 17287573, 701 | "name": "Jatiluwih Rice Terraces, Bali", 702 | "description": "", 703 | "camera": null, 704 | "lens": null, 705 | "focal_length": null, 706 | "iso": null, 707 | "shutter_speed": null, 708 | "aperture": null, 709 | "times_viewed": 18, 710 | "rating": 80.4, 711 | "status": 1, 712 | "created_at": "2017-10-01T10:20:04-04:00", 713 | "category": 8, 714 | "location": null, 715 | "latitude": 0, 716 | "longitude": 0, 717 | "taken_at": null, 718 | "hi_res_uploaded": 0, 719 | "for_sale": false, 720 | "width": 2448, 721 | "height": 1835, 722 | "votes_count": 12, 723 | "favorites_count": 0, 724 | "comments_count": 0, 725 | "nsfw": false, 726 | "sales_count": 0, 727 | "for_sale_date": null, 728 | "highest_rating": 80.4, 729 | "highest_rating_date": "2017-10-01T10:25:00-04:00", 730 | "license_type": 0, 731 | "converted": 0, 732 | "collections_count": 0, 733 | "crop_version": 0, 734 | "privacy": false, 735 | "profile": true, 736 | "for_critique": false, 737 | "critiques_callout_dismissed": false, 738 | "image_url": "https://drscdn.500px.org/photo/230199989/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=2de8c648c7f718deb8902ddb53402d5c3a0f6bacdeff7fdb23e3abee2c67830a", 739 | "images": [ 740 | { 741 | "size": 3, 742 | "url": "https://drscdn.500px.org/photo/230199989/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=2de8c648c7f718deb8902ddb53402d5c3a0f6bacdeff7fdb23e3abee2c67830a", 743 | "https_url": "https://drscdn.500px.org/photo/230199989/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=2de8c648c7f718deb8902ddb53402d5c3a0f6bacdeff7fdb23e3abee2c67830a", 744 | "format": "jpeg" 745 | } 746 | ], 747 | "url": "/photo/230199989/jatiluwih-rice-terraces-bali-by-nora-lovell", 748 | "positive_votes_count": 12, 749 | "converted_bits": 0, 750 | "store_download": false, 751 | "store_print": false, 752 | "store_license": false, 753 | "request_to_buy_enabled": true, 754 | "license_requests_enabled": false, 755 | "store_width": 2448, 756 | "store_height": 1835, 757 | "voted": false, 758 | "liked": false, 759 | "disliked": false, 760 | "purchased": false, 761 | "watermark": false, 762 | "image_format": "jpeg", 763 | "user": { 764 | "id": 17287573, 765 | "username": "noralovell", 766 | "firstname": "Nora", 767 | "lastname": "Lovell", 768 | "city": "", 769 | "country": "", 770 | "usertype": 0, 771 | "fullname": "Nora Lovell", 772 | "userpic_url": "https://pacdn.500px.org/17287573/09d865bb21a9c56ae5214a8844e9b1da84137f5f/1.jpg?18", 773 | "userpic_https_url": "https://pacdn.500px.org/17287573/09d865bb21a9c56ae5214a8844e9b1da84137f5f/1.jpg?18", 774 | "cover_url": "https://pacdn.500px.org/17287573/09d865bb21a9c56ae5214a8844e9b1da84137f5f/cover_2048.jpg?11", 775 | "upgrade_status": 3, 776 | "store_on": false, 777 | "affection": 669, 778 | "avatars": { 779 | "default": { 780 | "https": "https://pacdn.500px.org/17287573/09d865bb21a9c56ae5214a8844e9b1da84137f5f/1.jpg?18" 781 | }, 782 | "large": { 783 | "https": "https://pacdn.500px.org/17287573/09d865bb21a9c56ae5214a8844e9b1da84137f5f/2.jpg?18" 784 | }, 785 | "small": { 786 | "https": "https://pacdn.500px.org/17287573/09d865bb21a9c56ae5214a8844e9b1da84137f5f/3.jpg?18" 787 | }, 788 | "tiny": { 789 | "https": "https://pacdn.500px.org/17287573/09d865bb21a9c56ae5214a8844e9b1da84137f5f/4.jpg?18" 790 | } 791 | } 792 | }, 793 | "licensing_requested": false, 794 | "licensing_suggested": false, 795 | "is_free_photo": false 796 | }, 797 | { 798 | "id": 230199983, 799 | "user_id": 2540877, 800 | "name": "sea, my sea", 801 | "description": null, 802 | "camera": "Canon EOS 50D", 803 | "lens": "EF17-40mm f/4L USM", 804 | "focal_length": "40", 805 | "iso": "100", 806 | "shutter_speed": "1/2000", 807 | "aperture": "5.6", 808 | "times_viewed": 24, 809 | "rating": 83.3, 810 | "status": 1, 811 | "created_at": "2017-10-01T10:19:56-04:00", 812 | "category": 22, 813 | "location": null, 814 | "latitude": null, 815 | "longitude": null, 816 | "taken_at": "2017-09-20T10:27:12-04:00", 817 | "hi_res_uploaded": 0, 818 | "for_sale": false, 819 | "width": 1600, 820 | "height": 900, 821 | "votes_count": 15, 822 | "favorites_count": 0, 823 | "comments_count": 0, 824 | "nsfw": false, 825 | "sales_count": 0, 826 | "for_sale_date": null, 827 | "highest_rating": 83.3, 828 | "highest_rating_date": "2017-10-01T10:25:49-04:00", 829 | "license_type": 0, 830 | "converted": 0, 831 | "collections_count": 0, 832 | "crop_version": 0, 833 | "privacy": false, 834 | "profile": true, 835 | "for_critique": false, 836 | "critiques_callout_dismissed": false, 837 | "image_url": "https://drscdn.500px.org/photo/230199983/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=294ed4b81b95be63639841c7e8bb310812db366dd9537c6500ed9de4d558f13a", 838 | "images": [ 839 | { 840 | "size": 3, 841 | "url": "https://drscdn.500px.org/photo/230199983/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=294ed4b81b95be63639841c7e8bb310812db366dd9537c6500ed9de4d558f13a", 842 | "https_url": "https://drscdn.500px.org/photo/230199983/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=294ed4b81b95be63639841c7e8bb310812db366dd9537c6500ed9de4d558f13a", 843 | "format": "jpeg" 844 | } 845 | ], 846 | "url": "/photo/230199983/sea-my-sea-by-joanna-rze%C5%BAnikowska", 847 | "positive_votes_count": 15, 848 | "converted_bits": 0, 849 | "store_download": false, 850 | "store_print": false, 851 | "store_license": false, 852 | "request_to_buy_enabled": false, 853 | "license_requests_enabled": true, 854 | "voted": false, 855 | "liked": false, 856 | "disliked": false, 857 | "purchased": false, 858 | "watermark": false, 859 | "image_format": "jpeg", 860 | "user": { 861 | "id": 2540877, 862 | "username": "asiarzeznikowska", 863 | "firstname": "Joanna", 864 | "lastname": "Rzeźnikowska", 865 | "city": "Toruń", 866 | "country": "Poland", 867 | "usertype": 0, 868 | "fullname": "Joanna Rzeźnikowska", 869 | "userpic_url": "https://pacdn.500px.org/2540877/400bffe30aabaa620623ed06846eaf7651fc1d85/1.jpg?4", 870 | "userpic_https_url": "https://pacdn.500px.org/2540877/400bffe30aabaa620623ed06846eaf7651fc1d85/1.jpg?4", 871 | "cover_url": "https://pacdn.500px.org/2540877/400bffe30aabaa620623ed06846eaf7651fc1d85/cover_2048.jpg?4", 872 | "upgrade_status": 2, 873 | "store_on": true, 874 | "affection": 71986, 875 | "avatars": { 876 | "default": { 877 | "https": "https://pacdn.500px.org/2540877/400bffe30aabaa620623ed06846eaf7651fc1d85/1.jpg?4" 878 | }, 879 | "large": { 880 | "https": "https://pacdn.500px.org/2540877/400bffe30aabaa620623ed06846eaf7651fc1d85/2.jpg?4" 881 | }, 882 | "small": { 883 | "https": "https://pacdn.500px.org/2540877/400bffe30aabaa620623ed06846eaf7651fc1d85/3.jpg?4" 884 | }, 885 | "tiny": { 886 | "https": "https://pacdn.500px.org/2540877/400bffe30aabaa620623ed06846eaf7651fc1d85/4.jpg?4" 887 | } 888 | } 889 | }, 890 | "licensing_requested": false, 891 | "licensing_suggested": false, 892 | "is_free_photo": false 893 | }, 894 | { 895 | "id": 230199965, 896 | "user_id": 8561787, 897 | "name": "Untitled", 898 | "description": "", 899 | "camera": null, 900 | "lens": null, 901 | "focal_length": null, 902 | "iso": null, 903 | "shutter_speed": null, 904 | "aperture": null, 905 | "times_viewed": 26, 906 | "rating": 83.9, 907 | "status": 1, 908 | "created_at": "2017-10-01T10:19:38-04:00", 909 | "category": 0, 910 | "location": null, 911 | "latitude": null, 912 | "longitude": null, 913 | "taken_at": null, 914 | "hi_res_uploaded": 0, 915 | "for_sale": false, 916 | "width": 1083, 917 | "height": 600, 918 | "votes_count": 16, 919 | "favorites_count": 0, 920 | "comments_count": 0, 921 | "nsfw": false, 922 | "sales_count": 0, 923 | "for_sale_date": null, 924 | "highest_rating": 83.9, 925 | "highest_rating_date": "2017-10-01T10:24:31-04:00", 926 | "license_type": 0, 927 | "converted": 0, 928 | "collections_count": 0, 929 | "crop_version": 0, 930 | "privacy": false, 931 | "profile": true, 932 | "for_critique": false, 933 | "critiques_callout_dismissed": false, 934 | "image_url": "https://drscdn.500px.org/photo/230199965/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=1719dd13a24f4a7146aad5d3727ea380ed704f6b9681b3aa552abf776254b180", 935 | "images": [ 936 | { 937 | "size": 3, 938 | "url": "https://drscdn.500px.org/photo/230199965/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=1719dd13a24f4a7146aad5d3727ea380ed704f6b9681b3aa552abf776254b180", 939 | "https_url": "https://drscdn.500px.org/photo/230199965/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=1719dd13a24f4a7146aad5d3727ea380ed704f6b9681b3aa552abf776254b180", 940 | "format": "jpeg" 941 | } 942 | ], 943 | "url": "/photo/230199965/untitled-by-esposito-corinne", 944 | "positive_votes_count": 16, 945 | "converted_bits": 0, 946 | "store_download": false, 947 | "store_print": false, 948 | "store_license": false, 949 | "request_to_buy_enabled": true, 950 | "license_requests_enabled": false, 951 | "voted": false, 952 | "liked": false, 953 | "disliked": false, 954 | "purchased": false, 955 | "watermark": false, 956 | "image_format": "jpeg", 957 | "user": { 958 | "id": 8561787, 959 | "username": "corinneespositocombet", 960 | "firstname": "Esposito ", 961 | "lastname": "Corinne", 962 | "city": "Lyon", 963 | "country": "France", 964 | "usertype": 0, 965 | "fullname": "Esposito Corinne", 966 | "userpic_url": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/1.jpg?2", 967 | "userpic_https_url": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/1.jpg?2", 968 | "cover_url": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/cover_2048.jpg?2", 969 | "upgrade_status": 0, 970 | "store_on": false, 971 | "affection": 528, 972 | "avatars": { 973 | "default": { 974 | "https": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/1.jpg?2" 975 | }, 976 | "large": { 977 | "https": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/2.jpg?2" 978 | }, 979 | "small": { 980 | "https": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/3.jpg?2" 981 | }, 982 | "tiny": { 983 | "https": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/4.jpg?2" 984 | } 985 | } 986 | }, 987 | "licensing_requested": false, 988 | "licensing_suggested": false, 989 | "is_free_photo": false 990 | }, 991 | { 992 | "id": 230199959, 993 | "user_id": 22762827, 994 | "name": "korea restaurant 韩国餐厅", 995 | "description": "", 996 | "camera": "", 997 | "lens": "", 998 | "focal_length": "", 999 | "iso": "", 1000 | "shutter_speed": "", 1001 | "aperture": "", 1002 | "times_viewed": 23, 1003 | "rating": 80.4, 1004 | "status": 1, 1005 | "created_at": "2017-10-01T10:19:31-04:00", 1006 | "category": 23, 1007 | "location": "", 1008 | "latitude": null, 1009 | "longitude": null, 1010 | "taken_at": null, 1011 | "hi_res_uploaded": 0, 1012 | "for_sale": false, 1013 | "width": 1538, 1014 | "height": 2048, 1015 | "votes_count": 12, 1016 | "favorites_count": 0, 1017 | "comments_count": 0, 1018 | "nsfw": false, 1019 | "sales_count": 0, 1020 | "for_sale_date": null, 1021 | "highest_rating": 80.4, 1022 | "highest_rating_date": "2017-10-01T10:22:31-04:00", 1023 | "license_type": 0, 1024 | "converted": 0, 1025 | "collections_count": 0, 1026 | "crop_version": 0, 1027 | "privacy": false, 1028 | "profile": true, 1029 | "for_critique": false, 1030 | "critiques_callout_dismissed": false, 1031 | "image_url": "https://drscdn.500px.org/photo/230199959/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=514948ffe9045a45eaa13a3b576c64f7a5e7a5d090ab7580fbbeeb166102e174", 1032 | "images": [ 1033 | { 1034 | "size": 3, 1035 | "url": "https://drscdn.500px.org/photo/230199959/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=514948ffe9045a45eaa13a3b576c64f7a5e7a5d090ab7580fbbeeb166102e174", 1036 | "https_url": "https://drscdn.500px.org/photo/230199959/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=514948ffe9045a45eaa13a3b576c64f7a5e7a5d090ab7580fbbeeb166102e174", 1037 | "format": "jpeg" 1038 | } 1039 | ], 1040 | "url": "/photo/230199959/korea-restaurant-%E9%9F%A9%E5%9B%BD%E9%A4%90%E5%8E%85-by-ysl", 1041 | "positive_votes_count": 12, 1042 | "converted_bits": 0, 1043 | "store_download": false, 1044 | "store_print": false, 1045 | "store_license": false, 1046 | "request_to_buy_enabled": true, 1047 | "license_requests_enabled": false, 1048 | "store_width": 1538, 1049 | "store_height": 2048, 1050 | "voted": false, 1051 | "liked": false, 1052 | "disliked": false, 1053 | "purchased": false, 1054 | "watermark": false, 1055 | "image_format": "jpeg", 1056 | "user": { 1057 | "id": 22762827, 1058 | "username": "vcg-jevons", 1059 | "firstname": "YSL", 1060 | "lastname": null, 1061 | "city": "shanghai", 1062 | "country": "China", 1063 | "usertype": 12, 1064 | "fullname": "YSL", 1065 | "userpic_url": "https://secure.gravatar.com/avatar/cf4c7da1bc69172ad6f79b076b709a4f?s=300&r=g&d=https://pacdn.500px.org/userpic.png", 1066 | "userpic_https_url": "https://secure.gravatar.com/avatar/cf4c7da1bc69172ad6f79b076b709a4f?s=300&r=g&d=https://pacdn.500px.org/userpic.png", 1067 | "cover_url": "https://pacdn.500px.org/22762827/fcd3785d5f5c72bfb411b6ca2dad9f04bff3fefa/cover_2048.jpg?2", 1068 | "upgrade_status": 0, 1069 | "store_on": false, 1070 | "affection": 1321, 1071 | "avatars": { 1072 | "default": { 1073 | "https": "https://secure.gravatar.com/avatar/cf4c7da1bc69172ad6f79b076b709a4f?s=300&r=g&d=https://pacdn.500px.org/userpic.png" 1074 | }, 1075 | "large": { 1076 | "https": "https://secure.gravatar.com/avatar/cf4c7da1bc69172ad6f79b076b709a4f?s=100&r=g&d=https://pacdn.500px.org/userpic.png" 1077 | }, 1078 | "small": { 1079 | "https": "https://secure.gravatar.com/avatar/cf4c7da1bc69172ad6f79b076b709a4f?s=50&r=g&d=https://pacdn.500px.org/userpic.png" 1080 | }, 1081 | "tiny": { 1082 | "https": "https://secure.gravatar.com/avatar/cf4c7da1bc69172ad6f79b076b709a4f?s=30&r=g&d=https://pacdn.500px.org/userpic.png" 1083 | } 1084 | } 1085 | }, 1086 | "licensing_requested": false, 1087 | "licensing_suggested": false, 1088 | "is_free_photo": false 1089 | }, 1090 | { 1091 | "id": 230199957, 1092 | "user_id": 23736789, 1093 | "name": "构成", 1094 | "description": "", 1095 | "camera": "", 1096 | "lens": "", 1097 | "focal_length": "", 1098 | "iso": "", 1099 | "shutter_speed": "", 1100 | "aperture": "", 1101 | "times_viewed": 23, 1102 | "rating": 80.4, 1103 | "status": 1, 1104 | "created_at": "2017-10-01T10:19:29-04:00", 1105 | "category": 6, 1106 | "location": "", 1107 | "latitude": null, 1108 | "longitude": null, 1109 | "taken_at": null, 1110 | "hi_res_uploaded": 0, 1111 | "for_sale": true, 1112 | "width": 932, 1113 | "height": 1620, 1114 | "votes_count": 12, 1115 | "favorites_count": 0, 1116 | "comments_count": 0, 1117 | "nsfw": false, 1118 | "sales_count": 0, 1119 | "for_sale_date": null, 1120 | "highest_rating": 80.4, 1121 | "highest_rating_date": "2017-10-01T10:24:18-04:00", 1122 | "license_type": 0, 1123 | "converted": 0, 1124 | "collections_count": 0, 1125 | "crop_version": 0, 1126 | "privacy": false, 1127 | "profile": true, 1128 | "for_critique": false, 1129 | "critiques_callout_dismissed": false, 1130 | "image_url": "https://drscdn.500px.org/photo/230199957/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=99c7c98c2f013a7428b5df6a038e38ecdd717fc77294b9f98fbc979e14c4036e", 1131 | "images": [ 1132 | { 1133 | "size": 3, 1134 | "url": "https://drscdn.500px.org/photo/230199957/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=99c7c98c2f013a7428b5df6a038e38ecdd717fc77294b9f98fbc979e14c4036e", 1135 | "https_url": "https://drscdn.500px.org/photo/230199957/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=99c7c98c2f013a7428b5df6a038e38ecdd717fc77294b9f98fbc979e14c4036e", 1136 | "format": "jpeg" 1137 | } 1138 | ], 1139 | "url": "/photo/230199957/%E6%9E%84%E6%88%90-by-%E5%A4%A7%E7%B1%B3%E6%B1%A4", 1140 | "positive_votes_count": 12, 1141 | "converted_bits": 0, 1142 | "store_download": false, 1143 | "store_print": false, 1144 | "store_license": false, 1145 | "request_to_buy_enabled": true, 1146 | "license_requests_enabled": false, 1147 | "voted": false, 1148 | "liked": false, 1149 | "disliked": false, 1150 | "purchased": false, 1151 | "watermark": false, 1152 | "image_format": "jpeg", 1153 | "user": { 1154 | "id": 23736789, 1155 | "username": "8243f9ac94490a9b9809cd6c3d8083271", 1156 | "firstname": "大米汤", 1157 | "lastname": null, 1158 | "city": "南京", 1159 | "country": "中国", 1160 | "usertype": 12, 1161 | "fullname": "大米汤", 1162 | "userpic_url": "https://pacdn.500px.org/23736789/013658ee69bd5954998e836f0b635acb6f02afc5/1.jpg?2", 1163 | "userpic_https_url": "https://pacdn.500px.org/23736789/013658ee69bd5954998e836f0b635acb6f02afc5/1.jpg?2", 1164 | "cover_url": null, 1165 | "upgrade_status": 0, 1166 | "store_on": false, 1167 | "affection": 1880, 1168 | "avatars": { 1169 | "default": { 1170 | "https": "https://pacdn.500px.org/23736789/013658ee69bd5954998e836f0b635acb6f02afc5/1.jpg?2" 1171 | }, 1172 | "large": { 1173 | "https": "https://pacdn.500px.org/23736789/013658ee69bd5954998e836f0b635acb6f02afc5/2.jpg?2" 1174 | }, 1175 | "small": { 1176 | "https": "https://pacdn.500px.org/23736789/013658ee69bd5954998e836f0b635acb6f02afc5/3.jpg?2" 1177 | }, 1178 | "tiny": { 1179 | "https": "https://pacdn.500px.org/23736789/013658ee69bd5954998e836f0b635acb6f02afc5/4.jpg?2" 1180 | } 1181 | } 1182 | }, 1183 | "licensing_requested": false, 1184 | "licensing_suggested": false, 1185 | "is_free_photo": false 1186 | }, 1187 | { 1188 | "id": 230199955, 1189 | "user_id": 278792, 1190 | "name": "Black beach", 1191 | "description": "HyperFocal: 0", 1192 | "camera": "IQ160", 1193 | "lens": "35 mm f/16-2.8", 1194 | "focal_length": "35", 1195 | "iso": "50", 1196 | "shutter_speed": "15", 1197 | "aperture": "11", 1198 | "times_viewed": 21, 1199 | "rating": 80.4, 1200 | "status": 1, 1201 | "created_at": "2017-10-01T10:19:29-04:00", 1202 | "category": 0, 1203 | "location": null, 1204 | "latitude": 64.963051, 1205 | "longitude": -19.020835, 1206 | "taken_at": "2017-09-11T16:06:34-04:00", 1207 | "hi_res_uploaded": 0, 1208 | "for_sale": false, 1209 | "width": 1400, 1210 | "height": 1049, 1211 | "votes_count": 12, 1212 | "favorites_count": 0, 1213 | "comments_count": 0, 1214 | "nsfw": false, 1215 | "sales_count": 0, 1216 | "for_sale_date": null, 1217 | "highest_rating": 80.4, 1218 | "highest_rating_date": "2017-10-01T10:25:55-04:00", 1219 | "license_type": 0, 1220 | "converted": 0, 1221 | "collections_count": 0, 1222 | "crop_version": 0, 1223 | "privacy": false, 1224 | "profile": true, 1225 | "for_critique": false, 1226 | "critiques_callout_dismissed": false, 1227 | "image_url": "https://drscdn.500px.org/photo/230199955/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=f1bd858fc6fbf1423f7ad71ad9dd00d4166c297802564e491249e939ce1bdd9c", 1228 | "images": [ 1229 | { 1230 | "size": 3, 1231 | "url": "https://drscdn.500px.org/photo/230199955/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=f1bd858fc6fbf1423f7ad71ad9dd00d4166c297802564e491249e939ce1bdd9c", 1232 | "https_url": "https://drscdn.500px.org/photo/230199955/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=f1bd858fc6fbf1423f7ad71ad9dd00d4166c297802564e491249e939ce1bdd9c", 1233 | "format": "jpeg" 1234 | } 1235 | ], 1236 | "url": "/photo/230199955/black-beach-by-esam-kabli", 1237 | "positive_votes_count": 12, 1238 | "converted_bits": 0, 1239 | "store_download": false, 1240 | "store_print": false, 1241 | "store_license": false, 1242 | "request_to_buy_enabled": true, 1243 | "license_requests_enabled": false, 1244 | "voted": false, 1245 | "liked": false, 1246 | "disliked": false, 1247 | "purchased": false, 1248 | "watermark": false, 1249 | "image_format": "jpeg", 1250 | "user": { 1251 | "id": 278792, 1252 | "username": "EsamKabli", 1253 | "firstname": "Esam", 1254 | "lastname": "Kabli", 1255 | "city": "Jeddah", 1256 | "country": "Saudi Arabia", 1257 | "usertype": 0, 1258 | "fullname": "Esam Kabli", 1259 | "userpic_url": "https://pacdn.500px.org/278792/5f818d38b3320e1c930f97007394be9db121cdc1/1.jpg?3", 1260 | "userpic_https_url": "https://pacdn.500px.org/278792/5f818d38b3320e1c930f97007394be9db121cdc1/1.jpg?3", 1261 | "cover_url": "https://pacdn.500px.org/278792/5f818d38b3320e1c930f97007394be9db121cdc1/cover_2048.jpg?1", 1262 | "upgrade_status": 0, 1263 | "store_on": false, 1264 | "affection": 2914, 1265 | "avatars": { 1266 | "default": { 1267 | "https": "https://pacdn.500px.org/278792/5f818d38b3320e1c930f97007394be9db121cdc1/1.jpg?3" 1268 | }, 1269 | "large": { 1270 | "https": "https://pacdn.500px.org/278792/5f818d38b3320e1c930f97007394be9db121cdc1/2.jpg?3" 1271 | }, 1272 | "small": { 1273 | "https": "https://pacdn.500px.org/278792/5f818d38b3320e1c930f97007394be9db121cdc1/3.jpg?3" 1274 | }, 1275 | "tiny": { 1276 | "https": "https://pacdn.500px.org/278792/5f818d38b3320e1c930f97007394be9db121cdc1/4.jpg?3" 1277 | } 1278 | } 1279 | }, 1280 | "licensing_requested": false, 1281 | "licensing_suggested": false, 1282 | "is_free_photo": false 1283 | }, 1284 | { 1285 | "id": 230199953, 1286 | "user_id": 8561787, 1287 | "name": "Untitled", 1288 | "description": "", 1289 | "camera": null, 1290 | "lens": null, 1291 | "focal_length": null, 1292 | "iso": null, 1293 | "shutter_speed": null, 1294 | "aperture": null, 1295 | "times_viewed": 25, 1296 | "rating": 82.5, 1297 | "status": 1, 1298 | "created_at": "2017-10-01T10:19:27-04:00", 1299 | "category": 0, 1300 | "location": null, 1301 | "latitude": null, 1302 | "longitude": null, 1303 | "taken_at": null, 1304 | "hi_res_uploaded": 0, 1305 | "for_sale": false, 1306 | "width": 1421, 1307 | "height": 960, 1308 | "votes_count": 14, 1309 | "favorites_count": 0, 1310 | "comments_count": 0, 1311 | "nsfw": false, 1312 | "sales_count": 0, 1313 | "for_sale_date": null, 1314 | "highest_rating": 82.5, 1315 | "highest_rating_date": "2017-10-01T10:24:25-04:00", 1316 | "license_type": 0, 1317 | "converted": 0, 1318 | "collections_count": 1, 1319 | "crop_version": 0, 1320 | "privacy": false, 1321 | "profile": true, 1322 | "for_critique": false, 1323 | "critiques_callout_dismissed": false, 1324 | "image_url": "https://drscdn.500px.org/photo/230199953/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=b3fd35b87b0d17d66b75b2e3932da9faf42dc7b06d045e2a027fcc3361f4127c", 1325 | "images": [ 1326 | { 1327 | "size": 3, 1328 | "url": "https://drscdn.500px.org/photo/230199953/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=b3fd35b87b0d17d66b75b2e3932da9faf42dc7b06d045e2a027fcc3361f4127c", 1329 | "https_url": "https://drscdn.500px.org/photo/230199953/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=b3fd35b87b0d17d66b75b2e3932da9faf42dc7b06d045e2a027fcc3361f4127c", 1330 | "format": "jpeg" 1331 | } 1332 | ], 1333 | "url": "/photo/230199953/untitled-by-esposito-corinne", 1334 | "positive_votes_count": 14, 1335 | "converted_bits": 0, 1336 | "store_download": false, 1337 | "store_print": false, 1338 | "store_license": false, 1339 | "request_to_buy_enabled": true, 1340 | "license_requests_enabled": false, 1341 | "voted": false, 1342 | "liked": false, 1343 | "disliked": false, 1344 | "purchased": false, 1345 | "watermark": false, 1346 | "image_format": "jpeg", 1347 | "user": { 1348 | "id": 8561787, 1349 | "username": "corinneespositocombet", 1350 | "firstname": "Esposito ", 1351 | "lastname": "Corinne", 1352 | "city": "Lyon", 1353 | "country": "France", 1354 | "usertype": 0, 1355 | "fullname": "Esposito Corinne", 1356 | "userpic_url": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/1.jpg?2", 1357 | "userpic_https_url": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/1.jpg?2", 1358 | "cover_url": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/cover_2048.jpg?2", 1359 | "upgrade_status": 0, 1360 | "store_on": false, 1361 | "affection": 528, 1362 | "avatars": { 1363 | "default": { 1364 | "https": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/1.jpg?2" 1365 | }, 1366 | "large": { 1367 | "https": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/2.jpg?2" 1368 | }, 1369 | "small": { 1370 | "https": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/3.jpg?2" 1371 | }, 1372 | "tiny": { 1373 | "https": "https://pacdn.500px.org/8561787/323c818c8fc8a03e3de0eb02aa0fccf0349817bc/4.jpg?2" 1374 | } 1375 | } 1376 | }, 1377 | "licensing_requested": false, 1378 | "licensing_suggested": false, 1379 | "is_free_photo": false 1380 | }, 1381 | { 1382 | "id": 230199951, 1383 | "user_id": 7837293, 1384 | "name": "Villaggio Di Pescatori", 1385 | "description": null, 1386 | "camera": "Canon EOS 5D Mark III", 1387 | "lens": "EF17-40mm f/4L USM", 1388 | "focal_length": "20", 1389 | "iso": "100", 1390 | "shutter_speed": "13", 1391 | "aperture": "14", 1392 | "times_viewed": 29, 1393 | "rating": 80.4, 1394 | "status": 1, 1395 | "created_at": "2017-10-01T10:19:26-04:00", 1396 | "category": 8, 1397 | "location": null, 1398 | "latitude": 67.9468184, 1399 | "longitude": 13.1322376000001, 1400 | "taken_at": "2017-07-29T12:29:41-04:00", 1401 | "hi_res_uploaded": 0, 1402 | "for_sale": false, 1403 | "width": 5760, 1404 | "height": 3840, 1405 | "votes_count": 12, 1406 | "favorites_count": 0, 1407 | "comments_count": 0, 1408 | "nsfw": false, 1409 | "sales_count": 0, 1410 | "for_sale_date": null, 1411 | "highest_rating": 80.4, 1412 | "highest_rating_date": "2017-10-01T10:25:56-04:00", 1413 | "license_type": 0, 1414 | "converted": 0, 1415 | "collections_count": 3, 1416 | "crop_version": 0, 1417 | "privacy": false, 1418 | "profile": true, 1419 | "for_critique": false, 1420 | "critiques_callout_dismissed": false, 1421 | "image_url": "https://drscdn.500px.org/photo/230199951/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=142142a4acd749a815f3bfdbcacede74fa1a7624386db0dde6cba343b89a7b0c", 1422 | "images": [ 1423 | { 1424 | "size": 3, 1425 | "url": "https://drscdn.500px.org/photo/230199951/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=142142a4acd749a815f3bfdbcacede74fa1a7624386db0dde6cba343b89a7b0c", 1426 | "https_url": "https://drscdn.500px.org/photo/230199951/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=142142a4acd749a815f3bfdbcacede74fa1a7624386db0dde6cba343b89a7b0c", 1427 | "format": "jpeg" 1428 | } 1429 | ], 1430 | "url": "/photo/230199951/villaggio-di-pescatori-by-p%C3%A5l-rune-lien", 1431 | "positive_votes_count": 12, 1432 | "converted_bits": 0, 1433 | "store_download": false, 1434 | "store_print": false, 1435 | "store_license": false, 1436 | "request_to_buy_enabled": true, 1437 | "license_requests_enabled": true, 1438 | "store_width": 5760, 1439 | "store_height": 3840, 1440 | "voted": false, 1441 | "liked": false, 1442 | "disliked": false, 1443 | "purchased": false, 1444 | "watermark": false, 1445 | "image_format": "jpeg", 1446 | "user": { 1447 | "id": 7837293, 1448 | "username": "prlien", 1449 | "firstname": "Pål Rune", 1450 | "lastname": "Lien", 1451 | "city": "Trondheim", 1452 | "country": "Norway", 1453 | "usertype": 0, 1454 | "fullname": "Pål Rune Lien", 1455 | "userpic_url": "https://pacdn.500px.org/7837293/9658edc5295d8f1eadbfb6661d21ce1878c7e7c9/1.jpg?2", 1456 | "userpic_https_url": "https://pacdn.500px.org/7837293/9658edc5295d8f1eadbfb6661d21ce1878c7e7c9/1.jpg?2", 1457 | "cover_url": "https://pacdn.500px.org/7837293/9658edc5295d8f1eadbfb6661d21ce1878c7e7c9/cover_2048.jpg?18", 1458 | "upgrade_status": 0, 1459 | "store_on": true, 1460 | "affection": 29570, 1461 | "avatars": { 1462 | "default": { 1463 | "https": "https://pacdn.500px.org/7837293/9658edc5295d8f1eadbfb6661d21ce1878c7e7c9/1.jpg?2" 1464 | }, 1465 | "large": { 1466 | "https": "https://pacdn.500px.org/7837293/9658edc5295d8f1eadbfb6661d21ce1878c7e7c9/2.jpg?2" 1467 | }, 1468 | "small": { 1469 | "https": "https://pacdn.500px.org/7837293/9658edc5295d8f1eadbfb6661d21ce1878c7e7c9/3.jpg?2" 1470 | }, 1471 | "tiny": { 1472 | "https": "https://pacdn.500px.org/7837293/9658edc5295d8f1eadbfb6661d21ce1878c7e7c9/4.jpg?2" 1473 | } 1474 | } 1475 | }, 1476 | "licensing_requested": false, 1477 | "licensing_suggested": false, 1478 | "is_free_photo": false 1479 | }, 1480 | { 1481 | "id": 230199935, 1482 | "user_id": 18192059, 1483 | "name": "Moose Bog", 1484 | "description": "New Hampshire", 1485 | "camera": null, 1486 | "lens": null, 1487 | "focal_length": null, 1488 | "iso": null, 1489 | "shutter_speed": null, 1490 | "aperture": null, 1491 | "times_viewed": 21, 1492 | "rating": 81.5, 1493 | "status": 1, 1494 | "created_at": "2017-10-01T10:19:16-04:00", 1495 | "category": 8, 1496 | "location": null, 1497 | "latitude": 44.764511, 1498 | "longitude": -71.7405807, 1499 | "taken_at": null, 1500 | "hi_res_uploaded": 0, 1501 | "for_sale": false, 1502 | "width": 4288, 1503 | "height": 2848, 1504 | "votes_count": 13, 1505 | "favorites_count": 0, 1506 | "comments_count": 0, 1507 | "nsfw": false, 1508 | "sales_count": 0, 1509 | "for_sale_date": null, 1510 | "highest_rating": 81.5, 1511 | "highest_rating_date": "2017-10-01T10:25:29-04:00", 1512 | "license_type": 0, 1513 | "converted": 0, 1514 | "collections_count": 0, 1515 | "crop_version": 0, 1516 | "privacy": false, 1517 | "profile": true, 1518 | "for_critique": false, 1519 | "critiques_callout_dismissed": false, 1520 | "image_url": "https://drscdn.500px.org/photo/230199935/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=342ad6f4b6310870227218367e52aaaaa0f9f69da4f757761ad2f3cdb8a2cd6d", 1521 | "images": [ 1522 | { 1523 | "size": 3, 1524 | "url": "https://drscdn.500px.org/photo/230199935/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=342ad6f4b6310870227218367e52aaaaa0f9f69da4f757761ad2f3cdb8a2cd6d", 1525 | "https_url": "https://drscdn.500px.org/photo/230199935/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=342ad6f4b6310870227218367e52aaaaa0f9f69da4f757761ad2f3cdb8a2cd6d", 1526 | "format": "jpeg" 1527 | } 1528 | ], 1529 | "url": "/photo/230199935/moose-bog-by-george-santos", 1530 | "positive_votes_count": 13, 1531 | "converted_bits": 0, 1532 | "store_download": false, 1533 | "store_print": false, 1534 | "store_license": false, 1535 | "request_to_buy_enabled": false, 1536 | "license_requests_enabled": true, 1537 | "store_width": 4288, 1538 | "store_height": 2848, 1539 | "voted": false, 1540 | "liked": false, 1541 | "disliked": false, 1542 | "purchased": false, 1543 | "watermark": true, 1544 | "image_format": "jpeg", 1545 | "user": { 1546 | "id": 18192059, 1547 | "username": "jcsrpma1", 1548 | "firstname": "George", 1549 | "lastname": "Santos", 1550 | "city": "", 1551 | "country": "", 1552 | "usertype": 0, 1553 | "fullname": "George Santos", 1554 | "userpic_url": "https://pacdn.500px.org/18192059/2c8f61ae979ccd7285ed15a8ab2e51d5d6c2f135/1.jpg?1", 1555 | "userpic_https_url": "https://pacdn.500px.org/18192059/2c8f61ae979ccd7285ed15a8ab2e51d5d6c2f135/1.jpg?1", 1556 | "cover_url": "https://pacdn.500px.org/18192059/2c8f61ae979ccd7285ed15a8ab2e51d5d6c2f135/cover_2048.jpg?7", 1557 | "upgrade_status": 2, 1558 | "store_on": true, 1559 | "affection": 12040, 1560 | "avatars": { 1561 | "default": { 1562 | "https": "https://pacdn.500px.org/18192059/2c8f61ae979ccd7285ed15a8ab2e51d5d6c2f135/1.jpg?1" 1563 | }, 1564 | "large": { 1565 | "https": "https://pacdn.500px.org/18192059/2c8f61ae979ccd7285ed15a8ab2e51d5d6c2f135/2.jpg?1" 1566 | }, 1567 | "small": { 1568 | "https": "https://pacdn.500px.org/18192059/2c8f61ae979ccd7285ed15a8ab2e51d5d6c2f135/3.jpg?1" 1569 | }, 1570 | "tiny": { 1571 | "https": "https://pacdn.500px.org/18192059/2c8f61ae979ccd7285ed15a8ab2e51d5d6c2f135/4.jpg?1" 1572 | } 1573 | } 1574 | }, 1575 | "licensing_requested": false, 1576 | "licensing_suggested": false, 1577 | "is_free_photo": false 1578 | }, 1579 | { 1580 | "id": 230199929, 1581 | "user_id": 20373817, 1582 | "name": "徐敏-外白渡桥-上海外滩", 1583 | "description": "", 1584 | "camera": "", 1585 | "lens": "", 1586 | "focal_length": "", 1587 | "iso": "", 1588 | "shutter_speed": "", 1589 | "aperture": "", 1590 | "times_viewed": 20, 1591 | "rating": 81.5, 1592 | "status": 1, 1593 | "created_at": "2017-10-01T10:19:13-04:00", 1594 | "category": 27, 1595 | "location": "", 1596 | "latitude": null, 1597 | "longitude": null, 1598 | "taken_at": null, 1599 | "hi_res_uploaded": 0, 1600 | "for_sale": false, 1601 | "width": 2048, 1602 | "height": 1356, 1603 | "votes_count": 13, 1604 | "favorites_count": 0, 1605 | "comments_count": 0, 1606 | "nsfw": false, 1607 | "sales_count": 0, 1608 | "for_sale_date": null, 1609 | "highest_rating": 81.5, 1610 | "highest_rating_date": "2017-10-01T10:23:01-04:00", 1611 | "license_type": 0, 1612 | "converted": 0, 1613 | "collections_count": 0, 1614 | "crop_version": 0, 1615 | "privacy": false, 1616 | "profile": true, 1617 | "for_critique": false, 1618 | "critiques_callout_dismissed": false, 1619 | "image_url": "https://drscdn.500px.org/photo/230199929/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=d21d772ac8eaca42b95dd05aea6ae92608cf5ecdd03e73db7003707773c0c949", 1620 | "images": [ 1621 | { 1622 | "size": 3, 1623 | "url": "https://drscdn.500px.org/photo/230199929/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=d21d772ac8eaca42b95dd05aea6ae92608cf5ecdd03e73db7003707773c0c949", 1624 | "https_url": "https://drscdn.500px.org/photo/230199929/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=d21d772ac8eaca42b95dd05aea6ae92608cf5ecdd03e73db7003707773c0c949", 1625 | "format": "jpeg" 1626 | } 1627 | ], 1628 | "url": "/photo/230199929/%E5%BE%90%E6%95%8F-%E5%A4%96%E7%99%BD%E6%B8%A1%E6%A1%A5-%E4%B8%8A%E6%B5%B7%E5%A4%96%E6%BB%A9-by-%E5%BE%90%E5%B0%8F%E7%BE%8A", 1629 | "positive_votes_count": 13, 1630 | "converted_bits": 0, 1631 | "store_download": false, 1632 | "store_print": false, 1633 | "store_license": false, 1634 | "request_to_buy_enabled": true, 1635 | "license_requests_enabled": false, 1636 | "store_width": 2048, 1637 | "store_height": 1356, 1638 | "voted": false, 1639 | "liked": false, 1640 | "disliked": false, 1641 | "purchased": false, 1642 | "watermark": false, 1643 | "image_format": "jpeg", 1644 | "user": { 1645 | "id": 20373817, 1646 | "username": "626e2a6b048b391b353a6e73fde8c7355", 1647 | "firstname": "徐小羊", 1648 | "lastname": null, 1649 | "city": "上海", 1650 | "country": "中国", 1651 | "usertype": 12, 1652 | "fullname": "徐小羊", 1653 | "userpic_url": "https://pacdn.500px.org/20373817/0061850064771866f88b550b3c06686afcdc3487/1.jpg?2", 1654 | "userpic_https_url": "https://pacdn.500px.org/20373817/0061850064771866f88b550b3c06686afcdc3487/1.jpg?2", 1655 | "cover_url": "https://pacdn.500px.org/20373817/0061850064771866f88b550b3c06686afcdc3487/cover_2048.jpg?2", 1656 | "upgrade_status": 0, 1657 | "store_on": false, 1658 | "affection": 598, 1659 | "avatars": { 1660 | "default": { 1661 | "https": "https://pacdn.500px.org/20373817/0061850064771866f88b550b3c06686afcdc3487/1.jpg?2" 1662 | }, 1663 | "large": { 1664 | "https": "https://pacdn.500px.org/20373817/0061850064771866f88b550b3c06686afcdc3487/2.jpg?2" 1665 | }, 1666 | "small": { 1667 | "https": "https://pacdn.500px.org/20373817/0061850064771866f88b550b3c06686afcdc3487/3.jpg?2" 1668 | }, 1669 | "tiny": { 1670 | "https": "https://pacdn.500px.org/20373817/0061850064771866f88b550b3c06686afcdc3487/4.jpg?2" 1671 | } 1672 | } 1673 | }, 1674 | "licensing_requested": false, 1675 | "licensing_suggested": false, 1676 | "is_free_photo": false 1677 | }, 1678 | { 1679 | "id": 230199915, 1680 | "user_id": 657864, 1681 | "name": "Flowering heath", 1682 | "description": null, 1683 | "camera": "SLT-A65V", 1684 | "lens": "100mm F2.8 Macro", 1685 | "focal_length": "90", 1686 | "iso": "100", 1687 | "shutter_speed": "1/13", 1688 | "aperture": "5", 1689 | "times_viewed": 26, 1690 | "rating": 81.3, 1691 | "status": 1, 1692 | "created_at": "2017-10-01T10:19:03-04:00", 1693 | "category": 18, 1694 | "location": null, 1695 | "latitude": 52.229114, 1696 | "longitude": 6.873679, 1697 | "taken_at": "2017-10-01T09:31:35-04:00", 1698 | "hi_res_uploaded": 0, 1699 | "for_sale": false, 1700 | "width": 1369, 1701 | "height": 2048, 1702 | "votes_count": 13, 1703 | "favorites_count": 0, 1704 | "comments_count": 0, 1705 | "nsfw": false, 1706 | "sales_count": 0, 1707 | "for_sale_date": null, 1708 | "highest_rating": 81.3, 1709 | "highest_rating_date": "2017-10-01T10:23:41-04:00", 1710 | "license_type": 0, 1711 | "converted": 0, 1712 | "collections_count": 0, 1713 | "crop_version": 0, 1714 | "privacy": false, 1715 | "profile": true, 1716 | "for_critique": false, 1717 | "critiques_callout_dismissed": false, 1718 | "image_url": "https://drscdn.500px.org/photo/230199915/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=dd0218b063b2205834f48f7b7e6519ea0816e4c6a6c184f5860352d930d5d472", 1719 | "images": [ 1720 | { 1721 | "size": 3, 1722 | "url": "https://drscdn.500px.org/photo/230199915/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=dd0218b063b2205834f48f7b7e6519ea0816e4c6a6c184f5860352d930d5d472", 1723 | "https_url": "https://drscdn.500px.org/photo/230199915/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=dd0218b063b2205834f48f7b7e6519ea0816e4c6a6c184f5860352d930d5d472", 1724 | "format": "jpeg" 1725 | } 1726 | ], 1727 | "url": "/photo/230199915/flowering-heath-by-gerhard-hoogterp", 1728 | "positive_votes_count": 13, 1729 | "converted_bits": 0, 1730 | "store_download": false, 1731 | "store_print": false, 1732 | "store_license": false, 1733 | "request_to_buy_enabled": false, 1734 | "license_requests_enabled": true, 1735 | "store_width": 1369, 1736 | "store_height": 2048, 1737 | "voted": false, 1738 | "liked": false, 1739 | "disliked": false, 1740 | "purchased": false, 1741 | "watermark": false, 1742 | "image_format": "jpeg", 1743 | "user": { 1744 | "id": 657864, 1745 | "username": "Jondor", 1746 | "firstname": "Gerhard", 1747 | "lastname": "Hoogterp", 1748 | "city": "Enschede", 1749 | "country": "Netherlands", 1750 | "usertype": 0, 1751 | "fullname": "Gerhard Hoogterp", 1752 | "userpic_url": "https://pacdn.500px.org/657864/5df770406ddcbb626bc7ed7bc488ee95cba512e2/1.jpg?127", 1753 | "userpic_https_url": "https://pacdn.500px.org/657864/5df770406ddcbb626bc7ed7bc488ee95cba512e2/1.jpg?127", 1754 | "cover_url": "https://pacdn.500px.org/657864/5df770406ddcbb626bc7ed7bc488ee95cba512e2/cover_2048.jpg?10", 1755 | "upgrade_status": 0, 1756 | "store_on": true, 1757 | "affection": 7362, 1758 | "avatars": { 1759 | "default": { 1760 | "https": "https://pacdn.500px.org/657864/5df770406ddcbb626bc7ed7bc488ee95cba512e2/1.jpg?127" 1761 | }, 1762 | "large": { 1763 | "https": "https://pacdn.500px.org/657864/5df770406ddcbb626bc7ed7bc488ee95cba512e2/2.jpg?127" 1764 | }, 1765 | "small": { 1766 | "https": "https://pacdn.500px.org/657864/5df770406ddcbb626bc7ed7bc488ee95cba512e2/3.jpg?127" 1767 | }, 1768 | "tiny": { 1769 | "https": "https://pacdn.500px.org/657864/5df770406ddcbb626bc7ed7bc488ee95cba512e2/4.jpg?127" 1770 | } 1771 | } 1772 | }, 1773 | "licensing_requested": false, 1774 | "licensing_suggested": false, 1775 | "is_free_photo": false 1776 | }, 1777 | { 1778 | "id": 230199905, 1779 | "user_id": 18770135, 1780 | "name": "Untitled", 1781 | "description": "", 1782 | "camera": null, 1783 | "lens": null, 1784 | "focal_length": null, 1785 | "iso": null, 1786 | "shutter_speed": null, 1787 | "aperture": null, 1788 | "times_viewed": 20, 1789 | "rating": 80.4, 1790 | "status": 1, 1791 | "created_at": "2017-10-01T10:19:02-04:00", 1792 | "category": 0, 1793 | "location": null, 1794 | "latitude": null, 1795 | "longitude": null, 1796 | "taken_at": null, 1797 | "hi_res_uploaded": 0, 1798 | "for_sale": false, 1799 | "width": 607, 1800 | "height": 1080, 1801 | "votes_count": 12, 1802 | "favorites_count": 0, 1803 | "comments_count": 0, 1804 | "nsfw": false, 1805 | "sales_count": 0, 1806 | "for_sale_date": null, 1807 | "highest_rating": 80.4, 1808 | "highest_rating_date": "2017-10-01T10:22:50-04:00", 1809 | "license_type": 0, 1810 | "converted": 0, 1811 | "collections_count": 0, 1812 | "crop_version": 0, 1813 | "privacy": false, 1814 | "profile": true, 1815 | "for_critique": false, 1816 | "critiques_callout_dismissed": false, 1817 | "image_url": "https://drscdn.500px.org/photo/230199905/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=feaafaf9d3619e021d36b9cef0fa06f36093f48f81b71fffb95361febb2717dd", 1818 | "images": [ 1819 | { 1820 | "size": 3, 1821 | "url": "https://drscdn.500px.org/photo/230199905/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=feaafaf9d3619e021d36b9cef0fa06f36093f48f81b71fffb95361febb2717dd", 1822 | "https_url": "https://drscdn.500px.org/photo/230199905/w%3D280_h%3D280/v2?client_application_id=11&user_id=824357&webp=true&v=0&sig=feaafaf9d3619e021d36b9cef0fa06f36093f48f81b71fffb95361febb2717dd", 1823 | "format": "jpeg" 1824 | } 1825 | ], 1826 | "url": "/photo/230199905/untitled-by-ninya-khachiuri", 1827 | "positive_votes_count": 12, 1828 | "converted_bits": 0, 1829 | "store_download": false, 1830 | "store_print": false, 1831 | "store_license": false, 1832 | "request_to_buy_enabled": true, 1833 | "license_requests_enabled": false, 1834 | "voted": false, 1835 | "liked": false, 1836 | "disliked": false, 1837 | "purchased": false, 1838 | "watermark": false, 1839 | "image_format": "jpeg", 1840 | "user": { 1841 | "id": 18770135, 1842 | "username": "khachiurinino", 1843 | "firstname": "Ninya", 1844 | "lastname": "Khachiuri", 1845 | "city": "Tbilisi", 1846 | "country": "Georgia", 1847 | "usertype": 0, 1848 | "fullname": "Ninya Khachiuri", 1849 | "userpic_url": "https://pacdn.500px.org/18770135/c450757bae2c9953a85a697661de71448791cf0e/1.jpg?2", 1850 | "userpic_https_url": "https://pacdn.500px.org/18770135/c450757bae2c9953a85a697661de71448791cf0e/1.jpg?2", 1851 | "cover_url": "https://pacdn.500px.org/18770135/c450757bae2c9953a85a697661de71448791cf0e/cover_2048.jpg?1", 1852 | "upgrade_status": 0, 1853 | "store_on": false, 1854 | "affection": 560, 1855 | "avatars": { 1856 | "default": { 1857 | "https": "https://pacdn.500px.org/18770135/c450757bae2c9953a85a697661de71448791cf0e/1.jpg?2" 1858 | }, 1859 | "large": { 1860 | "https": "https://pacdn.500px.org/18770135/c450757bae2c9953a85a697661de71448791cf0e/2.jpg?2" 1861 | }, 1862 | "small": { 1863 | "https": "https://pacdn.500px.org/18770135/c450757bae2c9953a85a697661de71448791cf0e/3.jpg?2" 1864 | }, 1865 | "tiny": { 1866 | "https": "https://pacdn.500px.org/18770135/c450757bae2c9953a85a697661de71448791cf0e/4.jpg?2" 1867 | } 1868 | }, 1869 | "following": false 1870 | }, 1871 | "licensing_requested": false, 1872 | "licensing_suggested": false, 1873 | "is_free_photo": false 1874 | } 1875 | ], 1876 | "filters": { 1877 | "category": false, 1878 | "exclude": false 1879 | }, 1880 | "feature": "popular" 1881 | } 1882 | 1883 | -------------------------------------------------------------------------------- /MVVM/MVVM/View/PhotoDetailView/PhotoDetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoDetailViewController.swift 3 | // MVVM 4 | // 5 | // Created by 이동건 on 29/06/2018. 6 | // Copyright © 2018 이동건. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class PhotoDetailViewController: UIViewController { 12 | 13 | var imageUrl: String? 14 | 15 | @IBOutlet weak var imageView: UIImageView! 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | if let imageUrl = imageUrl { 20 | imageView.sd_setImage(with: URL(string: imageUrl), completed: nil) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /MVVM/MVVM/View/PhotoListTableView/PhotoListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // MVVM 4 | // 5 | // Created by 이동건 on 29/06/2018. 6 | // Copyright © 2018 이동건. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SDWebImage 11 | 12 | class PhotoListViewController: UIViewController { 13 | 14 | //MARK: Outlets 15 | @IBOutlet weak var tableView: UITableView! 16 | @IBOutlet weak var activityIndicator: UIActivityIndicatorView! 17 | 18 | //MARK: ViewModel For TableView 19 | lazy var viewModel: PhotoListViewModel = { 20 | return PhotoListViewModel() 21 | }() 22 | 23 | //MARK: Life cycle 24 | override func viewDidLoad() { 25 | super.viewDidLoad() 26 | initializeTableView() 27 | initializeViewModel() 28 | } 29 | 30 | //MARK: Initiate UI Components 31 | func initializeTableView(){ 32 | title = "Popular" 33 | tableView.rowHeight = UITableView.automaticDimension 34 | tableView.estimatedRowHeight = 150 35 | } 36 | 37 | //MARK: Setup ViewModel 38 | func initializeViewModel(){ 39 | viewModel.showAlertClosure = { [weak self] () in 40 | DispatchQueue.main.async { 41 | if let message = self?.viewModel.alertMessage { 42 | self?.showAlert( message ) 43 | } 44 | } 45 | } 46 | 47 | viewModel.updateLoadingStatus = { [weak self] in 48 | DispatchQueue.main.async { 49 | let isLoading = self?.viewModel.isLoading ?? false 50 | 51 | if isLoading { 52 | self?.activityIndicator.startAnimating() 53 | UIView.animate(withDuration: 0.2, animations: { 54 | self?.tableView.alpha = 0 55 | }) 56 | }else{ 57 | self?.activityIndicator.stopAnimating() 58 | UIView.animate(withDuration: 0.2, animations: { 59 | self?.tableView.alpha = 1 60 | }) 61 | } 62 | } 63 | } 64 | 65 | viewModel.reloadTableViewClosure = { [weak self] in 66 | DispatchQueue.main.async { 67 | self?.tableView.reloadData() 68 | } 69 | } 70 | 71 | viewModel.requestFetchData() 72 | } 73 | 74 | //MARK: Setup Alert 75 | func showAlert( _ message: String ) { 76 | let alert = UIAlertController(title: "Alert", message: message, preferredStyle: .alert) 77 | alert.addAction( UIAlertAction(title: "Ok", style: .cancel, handler: nil)) 78 | self.present(alert, animated: true, completion: nil) 79 | } 80 | 81 | //MARK: Segue 82 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 83 | if let vc = segue.destination as? PhotoDetailViewController, 84 | let photo = viewModel.selectedPhoto { 85 | vc.imageUrl = photo.image_url 86 | } 87 | } 88 | } 89 | 90 | //MARK:- TableViewDelegate & DataSource 91 | extension PhotoListViewController: UITableViewDelegate, UITableViewDataSource { 92 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 93 | guard let cell = tableView.dequeueReusableCell(withIdentifier: "photoCellIdentifier", for: indexPath) as? PhotoListTableViewCell else { 94 | fatalError("Cell not exists in storyboard") 95 | } 96 | // get data from cellViewModel 97 | let cellVieWModel = viewModel.getCellViewModel(at: indexPath) 98 | cell.setupViews(viewModel: cellVieWModel) 99 | return cell 100 | } 101 | 102 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 103 | return viewModel.numberOfCells 104 | } 105 | 106 | func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 107 | return 150 108 | } 109 | 110 | func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { 111 | self.viewModel.userPressed(at: indexPath) 112 | if viewModel.isAllowSegue { 113 | return indexPath 114 | }else{ 115 | return nil 116 | } 117 | } 118 | } 119 | 120 | -------------------------------------------------------------------------------- /MVVM/MVVM/View/PhotoListTableView/PhotoListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoListViewModel.swift 3 | // MVVM 4 | // 5 | // Created by 이동건 on 29/06/2018. 6 | // Copyright © 2018 이동건. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PhotoListViewModel { 12 | //MARK: Properties 13 | let apiService: APIServiceProtocol 14 | private var photos: [Photo] = [Photo]() 15 | var selectedPhoto: Photo? 16 | var isAllowSegue: Bool = false 17 | 18 | //MARK: Observed Properties 19 | private var cellViewModels:[PhotoListCellViewModel] = [PhotoListCellViewModel]() { 20 | didSet{ 21 | // notify 22 | self.reloadTableViewClosure?() 23 | } 24 | } 25 | 26 | var numberOfCells: Int { 27 | return cellViewModels.count 28 | } 29 | 30 | var isLoading: Bool = false { 31 | didSet{ 32 | // notify 33 | self.updateLoadingStatus?() 34 | } 35 | } 36 | 37 | var alertMessage: String? { 38 | didSet{ 39 | // notify 40 | self.showAlertClosure?() 41 | } 42 | } 43 | 44 | //MARK: Binding Closures 45 | var reloadTableViewClosure: (()->())? 46 | var updateLoadingStatus: (()->())? 47 | var showAlertClosure: (()->())? 48 | 49 | //MARK: Initializer 50 | init( apiService: APIServiceProtocol = APIService()) { 51 | self.apiService = apiService 52 | } 53 | 54 | //MARK: Methods 55 | func requestFetchData(){ 56 | self.isLoading = true // trigger activity indicator startAnimating 57 | apiService.fetchPopularPhoto { [weak self] (success, photos, error) in 58 | // Compelete Fetching Data 59 | self?.isLoading = false // trigger activity indicator stopAnimating 60 | if let error = error { 61 | self?.alertMessage = error.rawValue 62 | } else { 63 | self?.processFetchedPhoto(photos: photos) 64 | } 65 | } 66 | } 67 | 68 | private func processFetchedPhoto( photos: [Photo] ) { 69 | self.photos = photos // Cache 70 | var viewModels = [PhotoListCellViewModel]() // TableViewCellViewModel 71 | photos.forEach({viewModels.append(createCellViewModel(photo: $0))}) 72 | self.cellViewModels = viewModels // trigger photoListTableView reloadData 73 | } 74 | 75 | func createCellViewModel( photo: Photo ) -> PhotoListCellViewModel { 76 | //Wrap a description 77 | var descTextContainer: [String] = [String]() 78 | if let camera = photo.camera { 79 | descTextContainer.append(camera) 80 | } 81 | if let description = photo.description { 82 | descTextContainer.append( description ) 83 | } 84 | let desc = descTextContainer.joined(separator: " - ") 85 | 86 | let dateFormatter = DateFormatter() 87 | dateFormatter.dateFormat = "yyyy-MM-dd" 88 | 89 | return PhotoListCellViewModel( titleText: photo.name, 90 | descText: desc, 91 | imageUrl: photo.image_url, 92 | dateText: dateFormatter.string(from: photo.created_at) ) 93 | } 94 | 95 | func getCellViewModel( at indexPath: IndexPath ) -> PhotoListCellViewModel { 96 | return cellViewModels[indexPath.row] 97 | } 98 | } 99 | 100 | //MARK:- User Interaction 101 | extension PhotoListViewModel { 102 | func userPressed(at indexPath: IndexPath) { 103 | let photo = self.photos[indexPath.row] 104 | if photo.for_sale { 105 | // allow segue 106 | self.isAllowSegue = true 107 | self.selectedPhoto = photo 108 | }else{ 109 | self.isAllowSegue = false 110 | self.selectedPhoto = nil 111 | // trigger alert 112 | self.alertMessage = "This item is not for sale" 113 | } 114 | } 115 | } 116 | 117 | -------------------------------------------------------------------------------- /MVVM/MVVM/View/PhotoTableViewCell/PhotoListCellViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoListCellViewModel.swift 3 | // MVVM 4 | // 5 | // Created by 이동건 on 29/06/2018. 6 | // Copyright © 2018 이동건. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct PhotoListCellViewModel { 12 | let titleText: String 13 | let descText: String 14 | let imageUrl: String 15 | let dateText: String 16 | } 17 | -------------------------------------------------------------------------------- /MVVM/MVVM/View/PhotoTableViewCell/PhotoListTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoListTableViewCell.swift 3 | // MVVM 4 | // 5 | // Created by 이동건 on 29/06/2018. 6 | // Copyright © 2018 이동건. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SDWebImage 11 | 12 | class PhotoListTableViewCell: UITableViewCell { 13 | 14 | @IBOutlet weak var mainImageView: UIImageView! 15 | @IBOutlet weak var descriptionLabel: UILabel! 16 | @IBOutlet weak var dateLabel: UILabel! 17 | @IBOutlet weak var nameLabel: UILabel! 18 | 19 | func setupViews(viewModel: PhotoListCellViewModel) { 20 | nameLabel.text = viewModel.titleText 21 | descriptionLabel.text = viewModel.descText 22 | dateLabel.text = viewModel.dateText 23 | mainImageView.sd_setImage(with: URL(string: viewModel.imageUrl), completed: nil 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [iOS] iOS Architecture 2 | 3 | ### 개요 4 | 5 | 사실 알고있고 직접 사용하는 아키텍쳐는 MVC가 전부였습니다. MVP, MVVM, VIPER 등등 여러 아키텍쳐에 대해 들어만 봤을 뿐 직접 공부해보고 도입해보지는 않았습니다. 6 | 7 | 하지만 Naver Hackday를 통해 프로그램 구조의 중요성을 깨달았습니다. 그리고 프로그램에 알맞는 구조를 생각하는 힘을 기르기 위해 아키텍쳐에 대해 공부해보고 이를 정리해보는 시간을 가지려 합니다. 8 | 9 | [iOS Architecture Patterns](https://medium.com/ios-os-x-development/ios-architecture-patterns-ecba4c38de52) 글을 바탕으로 여러 레퍼런스들을 참고하며 지속적으로 업데이트할 예정입니다. 10 | 11 | --- 12 | 13 | ### Architecture 14 | 15 | 여러 아키텍쳐 패턴을 본격적으로 들어가기 전 이런 아키텍쳐가 프로그램을 작성하는데 필요한 이유와 그 효과들을 먼저 공부해보고자 합니다. 16 | 17 | 구조를 생각하지 않고 프로그램을 작성한다고 프로그램이 돌아가지 않는 것은 아닙니다. 하지만 이런 프로그램은 가독성은 떨어지며 유지 보수에 굉장히 많은 비용이 듭니다. 또한 테스팅 단계에서는 테스팅 자체가 거의 불가능하거나 효과를 볼 수 없는 난관에 봉착할 수 있습니다. 18 | 19 | 단순히 모듈(클래스)을 역할별로 나누어 관리하는 것은 아키텍쳐라고 할 수 없습니다. 특정 기준으로 역할을 정의하며 이렇게 역할별로 나누어진 모듈(클래스)간의 관계를 유기적으로 형성시키는 것이 아키텍쳐라 할 수 있습니다. 20 | 21 | 아키텍쳐의 정답은 없습니다. 아키텍쳐는 각 프로젝트의 성격에 맞게 선택해야 합니다. 하지만 분명 좋은 아키텍쳐의 기준과 특징은 존재합니다. 22 | 23 | - Balanced **Distribution** : 객체들의 역할이 확실하며 이런 역할들이 균형잡혀 분배되어 있는지, 즉 각 모듈(클래스)이 독립적인지 24 | - 이러한 확고한 역할의 분배는 프로그램의 복잡도를 낮춘다. 25 | - 객체지향의 5원칙인 **SOLID**의 [**Single Responsibility**](https://en.wikipedia.org/wiki/Single_responsibility_principle)에 기반 26 | - *하나의 객체는 하나의 역할만을 가져야 한다는 원칙* 27 | - 모듈(클래스)의 독립성이 떨어지면 테스팅을 진행하는데 어려움이 있다. 28 | - **Testability** : 테스트를 진행할 수 있는지 29 | - 테스팅 과정은 런타임 중 발생하는 이슈를 사전에 찾아내기 위해 필요한 단계 30 | - 테스팅에 있어서 그 자체가 문제라기보다는 테스팅을 진행하려는 아키텍쳐가 문제인 경우 많다. 31 | - **Easy of Use** : 사용하기 쉬운지 32 | - 사용하기 쉬운지는 개발 속도와 관계가 있을 수 있다. 33 | - **Unidirectional Data Flow** : 단방향성의 데이터 흐름 34 | - 단순한 데이터의 흐름은 코드를 쉽게 이해할 수 있게 해주며 쉬운 디버깅을 제공한다. 여러 객체들을 오가는 데이터의 흐름은 옳지 않다. 35 | - Shared Resource의 사용도 기피해야한다. 에러가 발생하면 원인을 찾기 힘들어진다. 36 | 37 | 물론 이 세 가지의 기준을 완벽하게 충족시키는 아키텍쳐는 없습니다. 그렇기 때문에 진행하고 있는 프로젝트의 성격에 맞게 선택적으로 도입해야 합니다. 38 | 39 | 위의 **Distribution**은 크게 세 가지의 카테고리로 나누어 진행됩니다. 40 | 41 | - **Model** : 프로그램에서 사용되는 데이터의 조작이 일어나고 이를 담당하는 부분 42 | - **View** : 시각적인 부분으로 UI에 해당. (iOS 환경에서는 'UI' 접두어가 붙은 모든 것들이 이에 해당) 43 | - **Controller / Presenter / ViewModel** : **Model**과 **View** 사이의 중재자로 일반적으로 **View**를 통해 발생한 사용자의 액션을 다루며 필요시 이에 따른 **Model**에 값의 조정을 요청하며 **Model** 값의 변화에 맞게 **View** 를 갱신하는 역할 44 | 45 | 이렇게 세 가지의 기준으로 나누어 **Distribution을** 진행하게 된다면 **재사용성이 증가**하며 그들을 **독립적으로 테스팅**할 수 있게 됩니다. 46 | 47 | 그럼 이제 본젹적으로 많이 사용되고 유명한 아키텍쳐 패턴들을 하나씩 살펴보도록 하겠습니다. 48 | 49 | --- 50 | 51 | ### MVC 52 | 53 | - M : Model 54 | - V : View 55 | - C : Controller 56 | 57 | 먼저 살펴볼 아키텍쳐 패턴은 바로 MVC입니다. 가장 유명하며 자연스럽게 사용되는 아키텍쳐이기도 합니다. 저는 두 가지의 MVC 아키텍쳐를 살펴보려 합니다. 전통적인 MVC 아키텍쳐부터 시작하겠습니다. 58 | 59 | #### Traditional MVC 60 | 61 | 62 | 63 | 위의 다이어그램을 통해 우리는 Model, View 그리고 Controller, 이 세 요소가 서로 강하게 연결되어 있음을 알 수 있습니다. View는 사용자의 액션을 Controller에게 전달하고 Controller는 이에 따른 데이터의 갱신을 Model에게 요청합니다. 이렇게 Model에서 데이터의 갱신이 일어나고 Model은 이런 상태 변화를 View에게 전달합니다. 그렇게 되면 View 역시 갱신된 데이터에 맞추어 갱신됩니다. 64 | 65 | 이렇게 강하게 연결된 셋은 독립성이 낮기 때문에 이들 각각의 재사용성은 굉장히 떨어집니다. 그렇기 때문에 현재 iOS 개발에는 전통적인 MVC 아키텍쳐는 맞지 않다고 볼 수 있습니다. 66 | 67 | 그래서 애플에서는 새로운 MVC 아키텍쳐를 제시하였습니다. 이를 살펴보도록 하겠습니다. 68 | 69 | #### Apple's MVC 70 | 71 | 애플이 제시한 Cocoa MVC에서 Controller는 View와 Model의 중재자로 View와 Model의 직접적인 연결을 막습니다. 이는 전통적인 MVC보다 높은 독립성의 보장을 기대합니다. 하지만 이러한 기대가 실제 개발에 큰 효과를 가져올까요? 먼저 Cocoa MVC 패턴의 다이어그램을 살펴보도록 하겠습니다. 72 | 73 | 74 | 75 | 위의 다이어그램을 얼핏보면 View와 Model의 독립성이 보장되는 것으로 보입니다. 실제 개발은 어떻게 이루어질까요? 76 | 77 | Cocoa MVC 아키텍쳐에서 Controller의 역할은 `UIViewController`가 담당하게 됩니다. 그리고 `UIViewController`는 View를 소유하게 되고 View들의 Lify Cycle과 강하게 연결되게 됩니다. 그렇기 때문에 View와 Controller의 분리가 쉽지 않으며 Controller의 재사용이 어려워지고 이로인해 연관되어 있는 View의 재사용 역시 어려워집니다. 78 | 79 | 이렇게 View와 Controller가 강하게 연결되어 있기에 테스팅의 과정 역시 굉장히 힘들어집니다. 독립적이라고 말할 수 있는 것은 Model이 전부입니다. 그리고 View 위에서의 사용자의 액션과 이에 따른 메소드뿐만 아니라 `UIViewController`에서 일어나는 각종 행위로 (네트워크 통신, Delegation 등) Controller는 방대해지고 이를 흔히 **M**assive **V**iew**C**ontroller라고 부르기도 합니다. 80 | 81 | 그래서 실제 다이어그램은 다음과 같은 흐름을 갖게 됩니다. 82 | 83 | 84 | 85 | 이렇게 방대해진 `UIViewController`를 줄이는 행위, [View Controller Offloading](https://www.objc.io/issues/1-view-controllers/lighter-view-controllers/)은 iOS 개발자들에게 중요한 과제가 되었습니다. Cocoa MVC 아키텍쳐를 구현한 코드를 살펴보겠습니다. 86 | 87 | ```swift 88 | import UIKit 89 | import PlaygroundSupport 90 | 91 | struct Person { // Model 92 | let firstName:String 93 | let lastName:String 94 | } 95 | 96 | class GreetingViewController: UIViewController { // Controller 97 | 98 | var person:Person! 99 | 100 | // Views are belong to Controller => tightly COUPLED 101 | lazy var showGreetingButton: UIButton = { 102 | let button = UIButton() 103 | button.setTitle("Click me", for: .normal) 104 | button.setTitle("You badass", for: .highlighted) 105 | button.setTitleColor(UIColor.white, for: .normal) 106 | button.setTitleColor(UIColor.red, for: .highlighted) 107 | button.addTarget(self, action: #selector(didTapButton(sender:)), for: .touchUpInside) 108 | button.translatesAutoresizingMaskIntoConstraints = false 109 | return button 110 | }() 111 | 112 | var greetingLabel: UILabel = { 113 | let label = UILabel() 114 | label.textColor = UIColor.white 115 | label.textAlignment = .center 116 | label.translatesAutoresizingMaskIntoConstraints = false 117 | return label 118 | }() 119 | 120 | override func viewDidLoad() { 121 | super.viewDidLoad() 122 | self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480) 123 | self.setupLayout() 124 | } 125 | 126 | // Layout codes in Controller 127 | func setupLayout() { 128 | self.setupButton() 129 | self.setupLabel() 130 | } 131 | 132 | private func setupButton() { 133 | self.view.addSubview(showGreetingButton) 134 | showGreetingButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true 135 | showGreetingButton.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true 136 | } 137 | 138 | private func setupLabel() { 139 | self.view.addSubview(greetingLabel) 140 | greetingLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true 141 | greetingLabel.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -30).isActive = true 142 | } 143 | 144 | @objc func didTapButton(sender: UIButton) { // Update View 145 | self.greetingLabel.text = "Hello " + self.person.firstName + " " + self.person.lastName 146 | } 147 | } 148 | 149 | let model = Person(firstName: "Wasin", lastName: "Thonkaew") 150 | let vc = GreetingViewController() 151 | vc.person = model 152 | 153 | PlaygroundPage.current.liveView = vc.view 154 | ``` 155 | 156 | 위의 코드를 보면 View의 생성과 배치에 관련된 코드들도 Controller안에 위치하게 되고 이들을 갱신하는 코드 역시 Controller 안에 위치하게 됩니다. 코드로만 보아도 Controller와 View가 굉장히 강하게 연결되어 있다는 것을 볼 수 있습니다. 157 | 158 | 또한 View의 테스팅 과정 역시 Controller의 View Life Cycle 관련 메소드(`viewDidLoad`, `viewWillAppear` 등)의 호출이 없다면 진행할 수 없기 때문에 역시 이 둘이 강하게 연결되어 있다는 것을 확인할 수 있습니다. 159 | 160 | 그럼 여기서 MVC 아키텍쳐는 위에서 언급했던 좋은 아키텍쳐의 기준들에 얼마나 부합하는지 살펴보도록 하겠습니다. 161 | 162 | - **Distribution** : View와 Model은 확실히 분리되어 있습니다. 하지만 View와 Controller는 강하게 연결되어 있습니다. 163 | - **Testability** : View와 Controller가 강하게 연결되어 있기 때문에 오로지 Model만 테스팅을 진행할 수 있습니다. 164 | - **Easy of Use** : 여러 아키텍쳐 중 가장 적은 코드를 필요로 하며 가장 친숙한 아키텍쳐 패턴으로 많은 경험이 없는 개발자들도 쉽게 유지 보수할 수 있습니다. 165 | 166 | > 개발 진행 속도에 있어서는 가장 빠른 아키텍쳐 패턴이라할 수 있습니다. 167 | 168 | iOS 개발에 있어서 아키텍쳐에 크게 신경을 쓸 수 없거나 지식이 전무하다면 가장 사용하기 쉬운 패턴이 바로 MVC입니다. 하지만 이는 아주 작은 프로젝트라 하더라도 많은 유지 보수 비용이 들어가게 됩니다. 169 | 170 | --- 171 | 172 | ### MVP 173 | 174 | - M : Model 175 | - V : View (`UIView` 그리고/혹은 `UIViewController`) 176 | - P : Presenter 177 | 178 | 먼저 다이어그램을 살펴보도록 하겠습니다. 179 | 180 | 181 | 182 | 위에서 살펴본 Cocoa MVC와 굉장히 비슷한 모습을 하고있는 걸 확인할 수 있습니다. 그러면 실제로도 Cocoa MVC와 유사할까요? 전혀 그렇지 않습니다. 183 | 184 | 먼저 MVC와는 다르게 `UIView`나 `UIViewController` 둘 모두 View에 해당합니다. Cocoa MVC에서 `UIViewController`는 Controller에 해당했었고 그로인해 View와 강하게 연결되어 있었습니다. 이 둘을 View로 분류하는 대신 MVP 패턴에서는 Presenter라는 것이 등장합니다. 185 | 186 | Presenter는 Cocoa MVC와는 다르게 View(`UIView`, `UIViewController`)의 Life Cycle에 영향을 받지 않고 레이아웃 코드 역시 Presenter에 존재하지 않습니다. 하지만 보다 Controller의 역할답게 View를 데이터와 상태에 맞추어 갱신하는 역할을 갖게 됩니다. 즉 Presenter는 Model로 부터 갱신된 데이터를 받아와 뷰를 갱신하는 역할을 합니다. 187 | 188 | 위에서 언급했듯이 Cocoa MVC와 다르게 MVP 패턴에서 `UIViewController`와 이를 상속받는 클래스들은 Presenter(Controller)가 아니라 View에 해당합니다. 이는 보다 테스팅의 효과를 높일 수 있습니다. 189 | 190 | 코드로 살펴보도록 하겠습니다. 191 | 192 | ```swift 193 | import UIKit 194 | import PlaygroundSupport 195 | 196 | struct Person { // Model 197 | let firstName:String 198 | let lastName:String 199 | } 200 | 201 | protocol GreetingView:class { // View Protocol 202 | func setGreeting(greeting:String) 203 | } 204 | 205 | protocol GreetingViewPresenter { // Presenter Protocol 206 | init(view: GreetingView, person: Person) 207 | func showGreeting() 208 | } 209 | 210 | class GreetingPresenter : GreetingViewPresenter { // Presenter 211 | weak var view: GreetingView? 212 | let person: Person 213 | 214 | required init(view: GreetingView, person: Person) { 215 | self.view = view 216 | self.person = person 217 | } 218 | // 3. 219 | func showGreeting() { // Update View 220 | let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName 221 | self.view?.setGreeting(greeting: greeting) 222 | } 223 | } 224 | 225 | class GreetingViewController : UIViewController, GreetingView { // View 226 | var presenter: GreetingViewPresenter! 227 | ... 228 | // Properties 229 | 230 | override func viewDidLoad() { 231 | super.viewDidLoad() 232 | self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480) 233 | setupLayout() 234 | self.showGreetingButton.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) 235 | } 236 | ... 237 | // Layout Code 238 | // 2. 239 | @objc func didTapButton(button: UIButton) { 240 | self.presenter.showGreeting() // Send Action to Presenter 241 | } 242 | // 1. 243 | func setGreeting(greeting: String) { 244 | self.greetingLabel.text = greeting 245 | } 246 | // layout code goes here 247 | } 248 | // Present the view controller in the Live View window 249 | // Assembling of MVP 250 | let model = Person(firstName: "Wasin", lastName: "Thonkaew") 251 | let view = GreetingViewController() 252 | let presenter = GreetingPresenter(view: view, person: model) 253 | view.presenter = presenter 254 | 255 | PlaygroundPage.current.liveView = view 256 | ``` 257 | 258 | 다이어그램과 코드를 통해 살펴보고 가야할 몇 가지가 존재합니다. 259 | 260 | 먼저 View는 Presenter를 소유하고 있어야 하며 Presenter는 유저 액션, 데이터 갱신, 상태 갱신에 따라 View를 갱신해주어야 합니다. 이를 코드로써 구현할 때 View는 Presenter를 강한 참조로 소유하고 있고 Presenter는 약한 참조로 View를 단순히 가리키고만 있습니다. 그렇기 때문에 View의 Life Cycle의 영향과 레이아웃 코드와 액션 코드가 공존하는 등의 의존성에서는 벗어날 수 있지만 참조에 의한 1:1 의존성에서는 벗어날 수 없다는 한계가 존재합니다. 261 | 262 | 다음으로는 `GreetingViewController`을 살펴보도록 하겠습니다. 이 곳에는 레이아웃과 유저의 액션을 전달하는 코드만이 위치하게 됩니다. 실제로 흐름을 살펴보도록 하겠습니다. 263 | 264 | 1. 프로토콜 메소드로 뷰를 갱신하는 메소드를 **정의** (***호출이 아님을 명심하자.***) 265 | 2. View 위에 존재하는 버튼에 `.touchUpInside` 액션이 들어오면 View는 `didTapButton` 메소드를 통해 Presenter에 이러한 사실을 알립니다. 266 | 3. Presenter는 유저의 액션에 대해 Model로부터 값을 가져와 뷰를 갱시하는 메소드를 **호출** (**호출**이라는 행위는 Presenter에 의해 행해진다.) 267 | 268 | MVC와 마찬가지로 MVP는 좋은 아키텍쳐의 기준에 얼마나 부합하는지 살펴보도록 하겠습니다. 269 | 270 | - **Distribution** : 전통적인 MVC에서 발생한 Model과 View의 의존성 문제는 해결하였다. 하지만 참조에 의한 View와 Controller의 의존성은 존재하지만 비교적 셋 모두 역할별로 적절히 나누어져 있다고 말할 수 있습니다. 271 | - **Testability** : 각각의 요소를 독립적으로 테스팅하기 용이합니다. 272 | - **Easy of Use** : Presenter의 추가와 이를 구현하기 위한 프로토콜등의 추가로 코드가 MVC보다 길어집니다. 273 | 274 | --- 275 | 276 | ### MVVM 277 | 278 | > MVVM 패턴은 RxSwift에 대한 경험이 없는 관계로 다른 프레임워크를 사용하지 않고 MVVM을 소개하고 있는 [How not to get desperate with MVVM implementation](https://medium.com/flawless-app-stories/how-to-use-a-model-view-viewmodel-architecture-for-ios-46963c67be1b)을 참고하여 작성하였습니다. 279 | 280 | - M : Model 281 | - V : View 282 | - VM : ViewModel 283 | 284 | 먼저 다이어그램을 살펴보도록 하겠습니다. 285 | 286 | 287 | 288 | MVVM의 정의에 의하면 View는 오직 시각적인 요소로만 이루어져야 합니다. View에서는 레이아웃, 애니매이션 그리고 UI 요소들에 대한 초기화 작업 코드들만이 위치하게 됩니다. MVVM에서 View와 Model 사이에 ViewModel이 위치하게 됩니다. ViewModel은 View의 각 UI 요소들에 대한 인터페이스를 제공합니다. View의 UI 요소들과 ViewModel의 인터페이스를 연결시키는 작업을 "**바인딩(Binding)**" 이라고 합니다. 289 | 290 | MVVM에서 View의 비즈니스 로직은 ViewModel에 정의되어 있으며 이에 맞춰 View가 갱신됩니다. 예를들어 `Date` 를 `String` 으로 변환하는 작업은 ViewModel에서 진행되고 View에서는 이에 맞춰 갱신만 일어나게 됩니다. 그렇기 때문에 View가 어떻게 구성되어 있는지와 상관없이 View의 비즈니스 로직에 대해서 테스팅이 가능해집니다. 291 | 292 | 전체적인 흐름으로 보았을 때 ViewModel은 View로부터 사용자의 액션을 받아오고 Model로부턴 데이터를 받아와 이렇게 받아온 데이터를 View에서 보여줄 값(**Ready-To-Display Property**)으로 가공을 합니다. 그와 동시에 View는 ViewModel의 이러한 **Ready-To-Display Property** 값을 observing하고 있어 값이 갱신되면 이에 맞춰 View를 갱신하게 됩니다. 293 | 294 | MVP와 마찬가지로 `UIView`와 `UIViewController`를 View로 묶어 분류합니다. 그렇기 때문에 View에서는 다음의 작업들만 해주면 됩니다. 295 | 296 | 1. Initiate/Layout/Present UI components. 297 | 2. Bind UI components with the ViewModel. 298 | 299 | 그리고 ViewModel에서는 다음과 같은 작업을 해주면 됩니다. 300 | 301 | 1. Write controller logics such as pagination, error handling, etc. 302 | 2. Write presentational logic, provide interfaces to the View. 303 | 304 | 그럼 이제 이를 구현한 코드로 살펴보도록 하겠습니다. 코드로 바로 MVVM 아키텍쳐를 구현해보는 것이 아닌 MVC 아키텍쳐로 만들어진 프로젝트를 MVVM으로 고쳐가며 하나하나 살펴보도록 하겠습니다. 만들어 볼 예제는 기본적인 테이블 뷰와 그 셀의 세그로 연결되는 뷰 컨트롤러로 넘어가는 정도의 간단한 수준입니다. 305 | 306 | > 앱의 완성된 결과를 [링크](https://media.giphy.com/media/l4EoWSOY1kxeSHVvi/giphy.gif)를 통해 먼저 확인해주세요. 307 | 308 | **MVC version** 309 | 310 | 먼저 MVC 아키텍쳐로 구현한 몇몇 코드들을 살펴보도록 하겠습니다. 이 코드들은 상당히 낯에 익을 것이라고 예상됩니다! (저도 그랬거든요!) 311 | 312 | 예제에서 Model을 담당하는 `Photo` 구조체입니다. 313 | 314 | ```swift 315 | struct Photo { 316 | let id: Int 317 | let name: String 318 | let description: String? 319 | let created_at: Date 320 | let image_url: String 321 | let for_sale: Bool 322 | let camera: String? 323 | } 324 | ``` 325 | 326 | Model을 채워줄 데이터는 예제 내의 `APIService`를 사용하여 받아와 테이블 뷰에 뿌려주게 됩니다. 그 코드는 아래와 같습니다. 패치의 행위가 완료되면 테이블 뷰를 `reloadData()` 해줌으로써 셀을 데이터에 맞추어 갱신해주는 작업입니다. 327 | 328 | ```swift 329 | self?.activityIndicator.startAnimating() 330 | self.tableView.alpha = 0.0 331 | apiService.fetchPopularPhoto { [weak self] (success, photos, error) in DispatchQueue.main.async { 332 | self?.photos = photos 333 | self?.activityIndicator.stopAnimating() 334 | self?.tableView.alpha = 1.0 335 | self?.tableView.reloadData() 336 | } 337 | } 338 | ``` 339 | 340 | 그리고 `UITableViewDataSource` 프로토콜 메소드 역시 다음과 같은 모습일 것입니다. 341 | 342 | ```swift 343 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 344 | // .................... 345 | let photo = self.photos[indexPath.row] 346 | //Wrap the date 347 | let dateFormateer = DateFormatter() 348 | dateFormateer.dateFormat = "yyyy-MM-dd" 349 | cell.dateLabel.text = dateFormateer.string(from: photo.created_at) 350 | //..................... 351 | } 352 | 353 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 354 | return self.photos.count 355 | } 356 | ``` 357 | 358 | 위의 메소드는 뷰 컨트롤러에서 정의해주었고 화면에 뿌려주고 이를 가공하는 작업까지 모두 뷰 컨트롤러에서 진행되고 있는걸 확인하실 수 있습니다. 359 | 360 | 마지막으로 다음은 `UITableViewDelegate` 프로토콜 메소드를 구현한 것입니다. 361 | 362 | ```swift 363 | func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { 364 | let photo = self.photos[indexPath.row] 365 | if photo.for_sale { // If item is for sale 366 | self.selectedIndexPath = indexPath 367 | return indexPath 368 | }else { // If item is not for sale 369 | let alert = UIAlertController(title: "Not for sale", message: "This item is not for sale", preferredStyle: .alert) 370 | alert.addAction( UIAlertAction(title: "Ok", style: .cancel, handler: nil)) 371 | self.present(alert, animated: true, completion: nil) 372 | return nil 373 | } 374 | } 375 | ``` 376 | 377 | 셀을 선택한 사용자의 액션을 받고 선택한 셀에 따라 `alert`를 띄어줄지 뷰 컨트롤러로 넘어갈 것인지를 결정하고 실행하는 역할까지 뷰 컨트롤러의 하나의 메소드안에서 진행되고 있습니다. 378 | 379 | 이 문서를 위에서부터 읽어오셨다면 무언가 너무 강하게 연결되어 있다는 것을 느끼실 수 있습니다. 위의 코드들을 간략하게 소개하는 부분에서 언급한 것들뿐만 아니라 뷰 컨트롤러는 `APIService`에 대해 의존성 문제를 갖고 있습니다. 380 | 381 | 이렇게 많은 것들이 뷰 컨트롤러 내에서 강하게 연결되어 있고 의존성이 존재한다면 테스트 코드를 작성하기가 매우 까다로워질 것이고 원하는 테스팅 성능을 뽑아낼 수 없을 것입니다. 그럼 이제 이들을 분리하여 보다 테스팅에 용이할 수 있는 MVVM 아키텍쳐로 수정해보도록 하겠습니다. 382 | 383 | **MVVM version** 384 | 385 | 위의 문제점들을 해결하기 위해서는 가장 먼저 뷰 컨트롤러의 부담을 줄여주어야 합니다. 이를 위해 먼저 예제에서 필요한 UI 요소들을 살펴보고 그들을 비즈니스 로직과 레이아웃 로직을 분리해보도록 하겠습니다. 386 | 387 | 이 예제에서는 다음과 같이 세 가지의 UI 요소가 사용됩니다. 388 | 389 | 1. activityIndicator (Loading / Finish) 390 | 2. tableView (Show / Hide) 391 | 3. cells (title, description. created date) 392 | 393 | 이들을 View와 ViewModel로 나눈 것을 추상화한다면 다음과 같은 다이어그램으로 표현될 수 있을 것입니다. 394 | 395 | 396 | 397 | 각각의 UI 요소는 ViewModel의 프로퍼티에 일대일 대응합니다. 그럼 이런 바인딩을 구현하려면 어떻게 해야 할까요? 스위프트에서는 이러한 작업을 다음의 방법들로 구현할 수 있습니다. 398 | 399 | 1. KVO (Key-Value Observing) 패턴 400 | 2. RxSwift나 ReactiveCocoa같은 FRP(Functional Reactive Programming) 라이브러를 활용. 401 | 3. Delegation 402 | 4. Property Observer 403 | 404 | 저는 참고하고 있는 블로그의 글을 따라 Property Observer와 Closure를 사용하여 구현해보았습니다. 모양새와 사용 용도만을 코드로 간단히 살펴보자면 다음과 같습니다. 405 | 406 | ***ViewModel*** 407 | 408 | ```swift 409 | var prop: T { 410 | didSet{ // Property Observer 411 | self.propChanged?() 412 | } 413 | } 414 | ``` 415 | 416 | ***View*** 417 | 418 | ```swift 419 | viewModel.propChanged = { [weak self] in 420 | DispatchQueue.main.async { 421 | // View의 업데이트 작업. 422 | } 423 | } 424 | ``` 425 | 426 | View에서 ViewModel의 바인딩 Closure들을 구현해줌으로써 View의 갱신에 대한 코드를 정의해주고 값의 변화에 따른 뷰 갱신을 호출하는 행위는 ViewModel에 위치하게 됩니다. 즉 데이터에 따라 뷰의 갱신을 명령하는 행위는 ViewModel에서 이루어지게 됩니다. 427 | 428 | 이렇게 바인딩 과정을 통하면 ViewModel은 MVP의 Presenter에서 프로토콜의 형태로라도 View의 존재를 알던 것과는 다르게 전혀 View에 대한 어떠한 참조도 존재하지 않게 됩니다. 429 | 430 | 예제의 전체 코드는 현재 문서와 동일한 레포지터리에 있으므로 해당 폴더를 확인해주시기 바랍니다. 여기선 위와 같은 방식의 코드가 실제 어떻게 구현되었는지를 간단하게 살펴보도록 하겠습니다. 테이블 뷰에 데이터를 뿌려주기 위한 바인딩 Closure와 호출을 살펴보도록 하겠습니다. 431 | 432 | ***ViewModel*** 433 | 434 | ```swift 435 | let apiService: APIServiceProtocol 436 | 437 | //MARK: Initializer 438 | init( apiService: APIServiceProtocol = APIService()) { 439 | self.apiService = apiService 440 | } 441 | 442 | ... 443 | // Activity Indicator 444 | var isLoading: Bool = false { 445 | didSet{ 446 | // notify 447 | self.updateLoadingStatus?() 448 | } 449 | } 450 | // Table View 451 | private var cellViewModels:[PhotoListCellViewModel] = [PhotoListCellViewModel]() { 452 | didSet{ 453 | // notify 454 | self.reloadTableViewClosure?() 455 | } 456 | } 457 | // Number of cells 458 | var numberOfCells: Int { 459 | return cellViewModels.count 460 | } 461 | 462 | //MARK: Binding Closures 463 | var reloadTableViewClosure: (()->())? 464 | var updateLoadingStatus: (()->())? 465 | ... 466 | 467 | // Request Data 468 | func requestFetchData(){ 469 | self.isLoading = true // trigger activity indicator startAnimating 470 | apiService.fetchPopularPhoto { [weak self] (success, photos, error) in 471 | // Compelete Fetching Data 472 | self?.isLoading = false // trigger activity indicator stopAnimating 473 | if let error = error { 474 | self?.alertMessage = error.rawValue 475 | }else { 476 | self?.processFetchedPhoto(photos: photos) 477 | } 478 | } 479 | } 480 | // Generate cell's ViewModel 481 | private func processFetchedPhoto( photos: [Photo] ) { 482 | self.photos = photos // Cache 483 | var viewModels = [PhotoListCellViewModel]() // TableViewCellViewModel 484 | photos.forEach({viewModels.append(createCellViewModel(photo: $0))}) 485 | self.cellViewModels = viewModels // trigger photoListTableView reloadData 486 | } 487 | 488 | // Get Cell 489 | func getCellViewModel( at indexPath: IndexPath ) -> PhotoListCellViewModel { 490 | return cellViewModels[indexPath.row] 491 | } 492 | ``` 493 | 494 | 가장 먼저 `APIService`는 더 이상 View(ViewController)에 위치하지 않습니다. 그다음 코드의 전체적인 흐름을 살펴보자면 `requestFetchData` 메소드가 호출되면 데이터를 받아오는 중과 끝난 상황에서 `isLoading`에 적절한 값을 할당해주어 `didSet`을 통한 View의 `activityIndicator` 행위를 조작해줄 수 있습니다. 495 | 496 | 그리고 데이터를 받아오는 행위가 정상적으로 완료되었다면 Cell의 ViewModel을 만드는 과정을 거쳐 `cellViewModels`에 테이블 뷰 위에 뿌려줄 데이터가 할당됩니다. 이렇게 값이 할당되면 역시 `didSet`을 통해 `tableView`의 `reloadData` 작업이 진행되는 것입니다. 그럼 이에 대한 View의 코드를 살펴보도록 하겠습니다. 497 | 498 | ***View*** 499 | 500 | ```swift 501 | //MARK: Outlets 502 | @IBOutlet weak var tableView: UITableView! 503 | @IBOutlet weak var activityIndicator: UIActivityIndicatorView! 504 | 505 | //MARK: ViewModel For TableView 506 | lazy var viewModel: PhotoListViewModel = { 507 | return PhotoListViewModel() 508 | }() 509 | 510 | //MARK: Life cycle 511 | override func viewDidLoad() { 512 | super.viewDidLoad() 513 | ... 514 | initializeViewModel() 515 | } 516 | 517 | //MARK: Setup ViewModel 518 | func initializeViewModel(){ 519 | ... 520 | 521 | viewModel.updateLoadingStatus = { [weak self] in 522 | DispatchQueue.main.async { 523 | let isLoading = self?.viewModel.isLoading ?? false 524 | if isLoading { 525 | self?.activityIndicator.startAnimating() 526 | UIView.animate(withDuration: 0.2, animations: { 527 | self?.tableView.alpha = 0 528 | }) 529 | }else{ 530 | self?.activityIndicator.stopAnimating() 531 | UIView.animate(withDuration: 0.2, animations: { 532 | self?.tableView.alpha = 1 533 | }) 534 | } 535 | } 536 | } 537 | 538 | viewModel.reloadTableViewClosure = { [weak self] in 539 | DispatchQueue.main.async { 540 | self?.tableView.reloadData() 541 | } 542 | } 543 | 544 | viewModel.requestFetchData() 545 | } 546 | 547 | //MARK: TableView DataSource 548 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 549 | guard let cell = tableView.dequeueReusableCell(withIdentifier: "photoCellIdentifier", for: indexPath) as? PhotoListTableViewCell else { 550 | fatalError("Cell not exists in storyboard") 551 | } 552 | // get data from cellViewModel 553 | let cellVieWModel = viewModel.getCellViewModel(at: indexPath) 554 | cell.setupViews(viewModel: cellVieWModel) 555 | return cell 556 | } 557 | 558 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 559 | return viewModel.numberOfCells 560 | } 561 | ``` 562 | 563 | `initializeViewModel` 메소드를 통해 ViewModel의 바인딩 Closure들을 정의해주고 마지막에 `requestFetchData` 메소드를 호출함으로써 데이터를 받아오는 작업을 시작합니다. 564 | 565 | `UITableViewDataSource` 프로토콜 메소드도 역시 ViewModel로부터 값을 받아와 사용하는 것을 확인할 수 있습니다. 566 | 567 | > User Interaction은 전체 코드에서 확인하실 수 있습니다. 568 | 569 | 그리하여 전체적인 그림은 다음과 같을 것입니다. 570 | 571 | 572 | 573 | **MVP와의 차이점** 574 | 575 | 제가 느끼기에 가장 큰 차이점은 Presenter는 View와 연결성이 약하지만 프로토콜로써 간접적으로 이를 참조하고 있고 ViewModel은 바인딩 작업을 통해 ViewModel에서 View에 관한 어떠한 의존성이나 연결성도 존재하지 않는다는 것입니다. 576 | 577 | MVVM이 물론 완벽하다고 할 순 없습니다. 다음은 MVVM의 단점을 소개하고 있는 글들입니다. 578 | 579 | - [MVVM is Not Very Good - Soroush Khanlou](http://khanlou.com/2015/12/mvvm-is-not-very-good/) 580 | - [The Problems with MVVM on iOS - Daniel Hall](http://www.danielhall.io/the-problems-with-mvvm-on-ios) 581 | 582 | 단점 중 하나가 바로 위에서 코드를 잠깐 살펴보았던 것과 마찬가지로 ViewModel에서 너무 많은 일들을 한다는 것도 하나의 문제점으로 지적되곤 합니다. 이를 해결하기 위해 실제로 Builder나 Router의 개념이 도입되었습니다. 역시 다음의 글들을 참고해주시기 바랍니다. 583 | 584 | - [Improve your iOS Architecture with FlowControllers](http://merowing.info/2016/01/improve-your-ios-architecture-with-flowcontrollers/) 585 | - [VIPER](https://www.objc.io/issues/13-architecture/viper/) 586 | - [Clean by Uncle Bob](https://hackernoon.com/introducing-clean-swift-architecture-vip-770a639ad7bf) 587 | 588 | --- 589 | 590 | ### VIPER 591 | --------------------------------------------------------------------------------