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