#3 iOS challenge

クローズ
lnastevski lnastevski/master から LiveLike/master への 1 コミットのマージを希望しています

+ 155 - 5
LiveLikeGiphyChallenge.xcodeproj/project.pbxproj

@@ -9,12 +9,23 @@
 /* Begin PBXBuildFile section */
 		2707607F276409C000064F59 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2707607E276409C000064F59 /* AppDelegate.swift */; };
 		27076081276409C000064F59 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27076080276409C000064F59 /* SceneDelegate.swift */; };
-		27076083276409C000064F59 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27076082276409C000064F59 /* ViewController.swift */; };
 		27076088276409C200064F59 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 27076087276409C200064F59 /* Assets.xcassets */; };
 		2707608B276409C200064F59 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 27076089276409C200064F59 /* LaunchScreen.storyboard */; };
 		27076096276409C300064F59 /* LiveLikeGiphyChallengeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27076095276409C300064F59 /* LiveLikeGiphyChallengeTests.swift */; };
 		270760A0276409C300064F59 /* LiveLikeGiphyChallengeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2707609F276409C300064F59 /* LiveLikeGiphyChallengeUITests.swift */; };
 		270760A2276409C300064F59 /* LiveLikeGiphyChallengeUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 270760A1276409C300064F59 /* LiveLikeGiphyChallengeUITestsLaunchTests.swift */; };
+		5F35B00527A6B6E7002C1111 /* GifListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B00427A6B6E7002C1111 /* GifListViewController.swift */; };
+		5F35B00727A6B700002C1111 /* GifListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B00627A6B700002C1111 /* GifListPresenter.swift */; };
+		5F35B01027A6BEF1002C1111 /* GifObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B00F27A6BEF1002C1111 /* GifObject.swift */; };
+		5F35B01427A6BF76002C1111 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B01327A6BF76002C1111 /* Constants.swift */; };
+		5F35B01727A6C041002C1111 /* GiphyApiManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B01627A6C041002C1111 /* GiphyApiManager.swift */; };
+		5F35B01B27A6C0C8002C1111 /* GifListPagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B01A27A6C0C8002C1111 /* GifListPagination.swift */; };
+		5F35B01D27A6C693002C1111 /* GiphyListResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B01C27A6C693002C1111 /* GiphyListResponse.swift */; };
+		5F35B02127A6E3BB002C1111 /* GifCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B02027A6E3BB002C1111 /* GifCollectionViewCell.swift */; };
+		5F35B02327A6F257002C1111 /* GifImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F35B02227A6F257002C1111 /* GifImage.swift */; };
+		5F84EADF27A7136B0008CA2B /* LLCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F84EADE27A7136B0008CA2B /* LLCache.swift */; };
+		5F84EAE427A721980008CA2B /* LLAnimatableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F84EAE327A721980008CA2B /* LLAnimatableImageView.swift */; };
+		5F84EAE627A880540008CA2B /* GifListCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F84EAE527A880540008CA2B /* GifListCollectionViewFlowLayout.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -38,7 +49,6 @@
 		2707607B276409C000064F59 /* LiveLikeGiphyChallenge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LiveLikeGiphyChallenge.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		2707607E276409C000064F59 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
 		27076080276409C000064F59 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
-		27076082276409C000064F59 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
 		27076087276409C200064F59 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		2707608A276409C200064F59 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 		2707608C276409C200064F59 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -47,6 +57,18 @@
 		2707609B276409C300064F59 /* LiveLikeGiphyChallengeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LiveLikeGiphyChallengeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		2707609F276409C300064F59 /* LiveLikeGiphyChallengeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLikeGiphyChallengeUITests.swift; sourceTree = "<group>"; };
 		270760A1276409C300064F59 /* LiveLikeGiphyChallengeUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLikeGiphyChallengeUITestsLaunchTests.swift; sourceTree = "<group>"; };
+		5F35B00427A6B6E7002C1111 /* GifListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifListViewController.swift; sourceTree = "<group>"; };
+		5F35B00627A6B700002C1111 /* GifListPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifListPresenter.swift; sourceTree = "<group>"; };
+		5F35B00F27A6BEF1002C1111 /* GifObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifObject.swift; sourceTree = "<group>"; };
+		5F35B01327A6BF76002C1111 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
+		5F35B01627A6C041002C1111 /* GiphyApiManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyApiManager.swift; sourceTree = "<group>"; };
+		5F35B01A27A6C0C8002C1111 /* GifListPagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifListPagination.swift; sourceTree = "<group>"; };
+		5F35B01C27A6C693002C1111 /* GiphyListResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyListResponse.swift; sourceTree = "<group>"; };
+		5F35B02027A6E3BB002C1111 /* GifCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifCollectionViewCell.swift; sourceTree = "<group>"; };
+		5F35B02227A6F257002C1111 /* GifImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifImage.swift; sourceTree = "<group>"; };
+		5F84EADE27A7136B0008CA2B /* LLCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLCache.swift; sourceTree = "<group>"; };
+		5F84EAE327A721980008CA2B /* LLAnimatableImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLAnimatableImageView.swift; sourceTree = "<group>"; };
+		5F84EAE527A880540008CA2B /* GifListCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifListCollectionViewFlowLayout.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -99,9 +121,13 @@
 			children = (
 				2707607E276409C000064F59 /* AppDelegate.swift */,
 				27076080276409C000064F59 /* SceneDelegate.swift */,
-				27076082276409C000064F59 /* ViewController.swift */,
-				27076087276409C200064F59 /* Assets.xcassets */,
+				5F35B00C27A6BAB6002C1111 /* ViewControllers */,
+				5F35AFFF27A6B659002C1111 /* UIElements */,
+				5F35B00027A6B668002C1111 /* GiphyApi */,
+				5F35B00827A6BA60002C1111 /* Utilities */,
 				27076089276409C200064F59 /* LaunchScreen.storyboard */,
+				5F35B01327A6BF76002C1111 /* Constants.swift */,
+				27076087276409C200064F59 /* Assets.xcassets */,
 				2707608C276409C200064F59 /* Info.plist */,
 			);
 			path = LiveLikeGiphyChallenge;
@@ -124,6 +150,117 @@
 			path = LiveLikeGiphyChallengeUITests;
 			sourceTree = "<group>";
 		};
+		5F35AFFD27A6B646002C1111 /* Views */ = {
+			isa = PBXGroup;
+			children = (
+				5F35B00427A6B6E7002C1111 /* GifListViewController.swift */,
+			);
+			path = Views;
+			sourceTree = "<group>";
+		};
+		5F35AFFE27A6B64E002C1111 /* Presenters */ = {
+			isa = PBXGroup;
+			children = (
+				5F35B00627A6B700002C1111 /* GifListPresenter.swift */,
+			);
+			path = Presenters;
+			sourceTree = "<group>";
+		};
+		5F35AFFF27A6B659002C1111 /* UIElements */ = {
+			isa = PBXGroup;
+			children = (
+				5F84EAE227A7217C0008CA2B /* ImageViews */,
+				5F35B02427A6F590002C1111 /* CollectionViewFlows */,
+				5F35B01E27A6E38A002C1111 /* CollectionViewCells */,
+			);
+			path = UIElements;
+			sourceTree = "<group>";
+		};
+		5F35B00027A6B668002C1111 /* GiphyApi */ = {
+			isa = PBXGroup;
+			children = (
+				5F35B00E27A6BED0002C1111 /* Models */,
+				5F35B00D27A6BEC4002C1111 /* Response */,
+				5F35B01627A6C041002C1111 /* GiphyApiManager.swift */,
+			);
+			path = GiphyApi;
+			sourceTree = "<group>";
+		};
+		5F35B00827A6BA60002C1111 /* Utilities */ = {
+			isa = PBXGroup;
+			children = (
+				5F35B00927A6BA75002C1111 /* ImageChache */,
+			);
+			path = Utilities;
+			sourceTree = "<group>";
+		};
+		5F35B00927A6BA75002C1111 /* ImageChache */ = {
+			isa = PBXGroup;
+			children = (
+				5F84EADE27A7136B0008CA2B /* LLCache.swift */,
+			);
+			path = ImageChache;
+			sourceTree = "<group>";
+		};
+		5F35B00C27A6BAB6002C1111 /* ViewControllers */ = {
+			isa = PBXGroup;
+			children = (
+				5F35AFFD27A6B646002C1111 /* Views */,
+				5F35AFFE27A6B64E002C1111 /* Presenters */,
+			);
+			path = ViewControllers;
+			sourceTree = "<group>";
+		};
+		5F35B00D27A6BEC4002C1111 /* Response */ = {
+			isa = PBXGroup;
+			children = (
+				5F35B01C27A6C693002C1111 /* GiphyListResponse.swift */,
+			);
+			path = Response;
+			sourceTree = "<group>";
+		};
+		5F35B00E27A6BED0002C1111 /* Models */ = {
+			isa = PBXGroup;
+			children = (
+				5F35B02227A6F257002C1111 /* GifImage.swift */,
+				5F35B00F27A6BEF1002C1111 /* GifObject.swift */,
+				5F35B01A27A6C0C8002C1111 /* GifListPagination.swift */,
+			);
+			path = Models;
+			sourceTree = "<group>";
+		};
+		5F35B01E27A6E38A002C1111 /* CollectionViewCells */ = {
+			isa = PBXGroup;
+			children = (
+				5F35B01F27A6E395002C1111 /* GifCollectionViewCell */,
+			);
+			path = CollectionViewCells;
+			sourceTree = "<group>";
+		};
+		5F35B01F27A6E395002C1111 /* GifCollectionViewCell */ = {
+			isa = PBXGroup;
+			children = (
+				5F35B02027A6E3BB002C1111 /* GifCollectionViewCell.swift */,
+			);
+			path = GifCollectionViewCell;
+			sourceTree = "<group>";
+		};
+		5F35B02427A6F590002C1111 /* CollectionViewFlows */ = {
+			isa = PBXGroup;
+			children = (
+				5F84EAE527A880540008CA2B /* GifListCollectionViewFlowLayout.swift */,
+			);
+			path = CollectionViewFlows;
+			sourceTree = "<group>";
+		};
+		5F84EAE227A7217C0008CA2B /* ImageViews */ = {
+			isa = PBXGroup;
+			children = (
+				5F84EAE327A721980008CA2B /* LLAnimatableImageView.swift */,
+			);
+			path = ImageViews;
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -254,9 +391,20 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
-				27076083276409C000064F59 /* ViewController.swift in Sources */,
 				2707607F276409C000064F59 /* AppDelegate.swift in Sources */,
+				5F35B01027A6BEF1002C1111 /* GifObject.swift in Sources */,
+				5F35B02127A6E3BB002C1111 /* GifCollectionViewCell.swift in Sources */,
 				27076081276409C000064F59 /* SceneDelegate.swift in Sources */,
+				5F35B01B27A6C0C8002C1111 /* GifListPagination.swift in Sources */,
+				5F84EAE627A880540008CA2B /* GifListCollectionViewFlowLayout.swift in Sources */,
+				5F35B00527A6B6E7002C1111 /* GifListViewController.swift in Sources */,
+				5F35B02327A6F257002C1111 /* GifImage.swift in Sources */,
+				5F84EADF27A7136B0008CA2B /* LLCache.swift in Sources */,
+				5F35B01D27A6C693002C1111 /* GiphyListResponse.swift in Sources */,
+				5F35B00727A6B700002C1111 /* GifListPresenter.swift in Sources */,
+				5F35B01727A6C041002C1111 /* GiphyApiManager.swift in Sources */,
+				5F84EAE427A721980008CA2B /* LLAnimatableImageView.swift in Sources */,
+				5F35B01427A6BF76002C1111 /* Constants.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -427,6 +575,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
 				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = "";
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_FILE = LiveLikeGiphyChallenge/Info.plist;
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -454,6 +603,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
 				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = "";
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_FILE = LiveLikeGiphyChallenge/Info.plist;
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;

+ 19 - 0
LiveLikeGiphyChallenge/Constants.swift

@@ -0,0 +1,19 @@
+//
+//  Constants.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import Foundation
+
+
+struct Constants {
+    
+    static let GiphyApiKey = "JUMJggLCBhTudfXShZ6fsFED1l4WV7Wa"
+    
+    static let GiphyPaginationLimit = 50
+    
+    static let GiphyBaseUrl = "https://api.giphy.com"
+
+}

+ 122 - 0
LiveLikeGiphyChallenge/GiphyApi/GiphyApiManager.swift

@@ -0,0 +1,122 @@
+//
+//  GiphyApiManager.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import Foundation
+
+enum GiphyApiError: Error {
+    case canceledByUser
+    case requestFailed
+    case decodingFailed
+}
+
+fileprivate struct RequestParamKeys {
+    static let ApiKey = "api_key"
+    static let QueryKey = "q"
+    static let OffsetKey = "offset"
+    static let LimitKey = "limit"
+}
+
+fileprivate struct Versions {
+    static let v1 = "/v1"
+}
+
+fileprivate struct Endpoints {
+    
+    static let Gifs = Versions.v1 + "/gifs"
+    
+    static let Trending = Gifs + "/trending"
+    static let Search = Gifs + "/search"
+    
+}
+
+
+class GiphyApiManager {
+    
+    static let shared = GiphyApiManager()
+    
+    private var baseUrl = URLComponents(string: Constants.GiphyBaseUrl)!
+        
+    private let session = URLSession(configuration: .ephemeral, delegate: nil, delegateQueue: nil)
+    
+    // Last searchable task
+    private var lastSearchableTask: URLSessionDataTask?
+    
+    init() {
+        
+        // Default query items
+        baseUrl.queryItems = [
+            URLQueryItem(name: RequestParamKeys.ApiKey, value: Constants.GiphyApiKey),
+            URLQueryItem(name: RequestParamKeys.LimitKey, value: String(Constants.GiphyPaginationLimit))
+        ]
+        
+    }
+    
+    func cancelLastSearchableTask() {
+        lastSearchableTask?.cancel()
+        lastSearchableTask = nil
+    }
+    
+    func cancelDataTaskForUrl(url: URL) {
+        
+        session.getAllTasks { tasks in
+              tasks.filter { $0.state == .running }.filter { $0.originalRequest?.url == url}.first?.cancel()
+            }
+        
+    }
+    
+    func getTrendingGifs(offset: Int, completion: @escaping (Result<GiphyListResponse, Error>) -> Void) {
+        
+        let offset = URLQueryItem(name: RequestParamKeys.OffsetKey, value: String(offset))
+        self.lastSearchableTask = self.getCodable(endpoint: Endpoints.Trending, queries: [offset], completion: completion)
+    }
+    
+    func getSearchGifs(offset: Int, query: String, completion: @escaping (Result<GiphyListResponse, Error>) -> Void) {
+        
+        let offset = URLQueryItem(name: RequestParamKeys.OffsetKey, value: String(offset))
+        let query = URLQueryItem(name: RequestParamKeys.QueryKey, value: query)
+
+        self.lastSearchableTask = self.getCodable(endpoint: Endpoints.Search, queries: [offset, query], completion: completion)
+    }
+    
+    func getData(url: URL, completion: @escaping (Data?) -> Void) -> URLSessionDataTask {
+        let task = session.dataTask(with: url) { data, _ , _ in
+            completion(data)
+        }
+        task.resume()
+        return task
+    }
+
+
+    private func getCodable<U:Codable>(endpoint: String, queries: [URLQueryItem]? = nil, completion: @escaping (Result<U, Error>) -> Void) -> URLSessionDataTask {
+        
+        var fullUrlComponent = self.baseUrl
+        
+        if let queries = queries {
+            fullUrlComponent.queryItems?.append(contentsOf: queries)
+        }
+
+        fullUrlComponent.path = endpoint
+        
+        
+        let task = session.dataTask(with: fullUrlComponent.url!) { d, _, e in
+            
+            guard let data = d else { completion(.failure(e!)); return }
+        
+            do {
+                let decoded = try JSONDecoder().decode(U.self, from: data)
+                completion(.success(decoded))
+            } catch {
+                completion(.failure(GiphyApiError.decodingFailed))
+            }
+            
+        }
+        
+        task.resume()
+        return task
+    }
+
+}

+ 27 - 0
LiveLikeGiphyChallenge/GiphyApi/Models/GifImage.swift

@@ -0,0 +1,27 @@
+//
+//  GifImage.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import Foundation
+
+struct GifImage: Codable {
+    
+    let url: String
+    let width: String
+    let height: String
+    let size: String?
+
+}
+
+struct GifImages: Codable {
+    
+    let fixedWidth: GifImage
+
+    enum CodingKeys: String, CodingKey {
+        case fixedWidth = "fixed_width"
+    }
+    
+}

+ 40 - 0
LiveLikeGiphyChallenge/GiphyApi/Models/GifListPagination.swift

@@ -0,0 +1,40 @@
+//
+//  GifListPagination.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import Foundation
+
+struct GifListPagination: Codable {
+    
+    let offset: Int
+    let totalCount: Int
+    let count: Int
+    
+    var nextOffset: Int {
+        return offset + count
+    }
+    
+    var canLoadMore: Bool {
+        return offset == 0 || self.count == Constants.GiphyPaginationLimit
+    }
+
+    init() {
+        offset = 0
+        totalCount = 0
+        count = 0
+    }
+    
+}
+
+extension GifListPagination {
+    
+    enum CodingKeys: String, CodingKey {
+        case offset = "offset"
+        case totalCount = "total_count"
+        case count = "count"
+    }
+    
+}

+ 16 - 0
LiveLikeGiphyChallenge/GiphyApi/Models/GifObject.swift

@@ -0,0 +1,16 @@
+//
+//  GifObject.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import Foundation
+
+struct GifObject: Codable {
+    
+    let id: String
+    let title: String
+    let images: GifImages
+    
+}

+ 15 - 0
LiveLikeGiphyChallenge/GiphyApi/Response/GiphyListResponse.swift

@@ -0,0 +1,15 @@
+//
+//  GiphyListResponse.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import Foundation
+
+struct GiphyListResponse: Codable {
+    
+    let data: [GifObject]
+    let pagination: GifListPagination
+    
+}

+ 1 - 1
LiveLikeGiphyChallenge/SceneDelegate.swift

@@ -20,7 +20,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
         guard let windowScene = (scene as? UIWindowScene) else { return }
                 window = UIWindow(frame: windowScene.coordinateSpace.bounds)
                 window?.windowScene = windowScene
-                window?.rootViewController = ViewController()
+                window?.rootViewController = UINavigationController(rootViewController: GifListViewController())
                 window?.makeKeyAndVisible()
     }
 

+ 74 - 0
LiveLikeGiphyChallenge/UIElements/CollectionViewCells/GifCollectionViewCell/GifCollectionViewCell.swift

@@ -0,0 +1,74 @@
+//
+//  GifCollectionViewCell.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import UIKit
+
+class GifCollectionViewCell: UICollectionViewCell {
+    
+    static let reuseIdentifier = NSStringFromClass(GifCollectionViewCell.self)
+    
+    private lazy var imageView: LLAnimatableImageView = {
+        let imageView = LLAnimatableImageView()
+        imageView.contentMode = .center
+        imageView.clipsToBounds = true
+        return imageView
+    }()
+    
+    private lazy var titleLabel: UILabel = {
+        let lbl = UILabel()
+        lbl.numberOfLines = 0
+        return lbl
+    }()
+    
+    var gif: GifObject? {
+        didSet{
+            self.didSetGif()
+        }
+    }
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        self.setupSelf()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+        
+    private func setupSelf() {
+        
+        self.contentView.addSubview(self.imageView)
+        self.contentView.addSubview(self.titleLabel)
+        
+        
+        self.imageView.translatesAutoresizingMaskIntoConstraints = false
+        self.titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        NSLayoutConstraint.activate([
+            
+            self.imageView.topAnchor.constraint(equalTo: self.contentView.topAnchor),
+            self.imageView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
+            self.imageView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor),
+            self.imageView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
+            
+            self.titleLabel.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
+            self.titleLabel.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
+            self.titleLabel.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor),
+            self.titleLabel.heightAnchor.constraint(lessThanOrEqualTo: self.contentView.heightAnchor, multiplier: 0.5)
+        ])
+    }
+    
+    private func didSetGif() {
+        
+        if let gif = gif {
+            self.titleLabel.text = gif.title
+            self.imageView.loadGifFromUrl(urlString: gif.images.fixedWidth.url)
+        }
+        
+    }
+    
+}

+ 26 - 0
LiveLikeGiphyChallenge/UIElements/CollectionViewFlows/GifListCollectionViewFlowLayout.swift

@@ -0,0 +1,26 @@
+//
+//  GifListCollectionViewFlowLayout.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 31.1.22.
+//
+
+import UIKit
+
+class GifListCollectionViewFlowLayout: UICollectionViewFlowLayout {
+    
+    override func prepare() {
+        
+        super.prepare()
+
+        guard let collectionView = collectionView else { return }
+        
+        let availableWidth = collectionView.bounds.inset(by: collectionView.layoutMargins).width
+        let cellWidth = (availableWidth / 2).rounded(.down)
+        
+        self.itemSize = CGSize(width: cellWidth, height: cellWidth)
+        self.sectionInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
+
+    }
+    
+}

+ 73 - 0
LiveLikeGiphyChallenge/UIElements/ImageViews/LLAnimatableImageView.swift

@@ -0,0 +1,73 @@
+//
+//  LLAnimatableImageView.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import UIKit
+
+class LLAnimatableImageView: UIImageView {
+    
+    var cache = LLCache.shared
+    
+    private var url: URL?
+    
+    private var worker: DispatchWorkItem?
+    
+    private var activityIndicator: UIActivityIndicatorView = {
+        let ai = UIActivityIndicatorView()
+        ai.translatesAutoresizingMaskIntoConstraints = false
+        ai.hidesWhenStopped = true
+        return ai
+    }()
+    
+    init() {
+        super.init(frame: .zero)
+        
+        self.addSubview(activityIndicator)
+        
+        NSLayoutConstraint.activate([
+            activityIndicator.centerXAnchor.constraint(equalTo: self.centerXAnchor),
+            activityIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor)
+        ])
+        
+        
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    
+    func loadGifFromUrl(urlString: String) {
+        
+        guard let url = URL(string: urlString) else { return }
+        self.url = url
+        
+        self.cancelAndClear()
+        
+        self.cache.retreiveDataFromUrl(url: url) {[weak self] gifImage in
+            
+            gifImage?.prepareForDisplay(completionHandler: { preparedImage in
+                DispatchQueue.main.async {
+                    self?.activityIndicator.stopAnimating()
+                    self?.image = preparedImage
+                }
+                
+            })
+
+        }
+
+    }
+
+    /// Sets the current image to nil
+    func cancelAndClear() {
+        
+        self.activityIndicator.startAnimating()
+        self.image = nil
+        cache.cancelTaskFor(url: url!)
+
+    }
+    
+}

+ 93 - 0
LiveLikeGiphyChallenge/Utilities/ImageChache/LLCache.swift

@@ -0,0 +1,93 @@
+//
+//  LLCache.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import Foundation
+import UIKit
+
+class LLCache: NSCache <NSString, UIImage> {
+        
+    static let shared: LLCache = LLCache()
+    
+    weak var activeTask: URLSessionDataTask?
+        
+    override init() {
+        super.init()
+        self.name = "com.livelike.gif_cache"
+    }
+    
+    
+    func retreiveDataFromUrl(url: URL, completion: @escaping (UIImage?) -> Void) {
+        
+        let path = url.path
+        
+        if let stored = self.object(forKey: path as NSString) {
+            completion(stored)
+            return
+        }
+        
+
+        activeTask = GiphyApiManager.shared.getData(url: url) { [weak self] data in
+
+            var preparedImage: UIImage?
+
+            if let data = data {
+                preparedImage = self?.prepareData(data: data)
+
+                if preparedImage != nil {
+                    self?.setObject(preparedImage!, forKey: path as NSString)
+                }
+
+            }
+
+            completion(preparedImage)
+
+        }
+                
+    }
+
+    func cancelTaskFor(url: URL) {
+        GiphyApiManager.shared.cancelDataTaskForUrl(url: url)
+    }
+    
+    /*!
+        Prepare the gif before saving it.
+        The loading will be faster.
+     */
+    private func prepareData(data: Data) -> UIImage? {
+        
+        guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
+
+        var images = [UIImage]()
+        let imageCount = CGImageSourceGetCount(source)
+        
+        for i in 0 ..< imageCount {
+            
+            if let image = CGImageSourceCreateImageAtIndex(source, i, nil) {
+                let img = self.drawInContext(image: UIImage(cgImage: image))
+                images.append(img)
+                
+            }
+        }
+
+        let animatedImage = UIImage.animatedImage(with: images, duration: TimeInterval(images.count * (1/15)))
+
+        return animatedImage
+        
+    }
+    
+    private func drawInContext(image: UIImage) -> UIImage {
+        
+        UIGraphicsBeginImageContextWithOptions(image.size, true, 1)
+        image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
+        let newImage = UIGraphicsGetImageFromCurrentImageContext()
+        UIGraphicsEndImageContext()
+
+        return newImage!
+    }
+
+    
+}

+ 0 - 19
LiveLikeGiphyChallenge/ViewController.swift

@@ -1,19 +0,0 @@
-//
-//  ViewController.swift
-//  LiveLikeGiphyChallenge
-//
-//
-
-import UIKit
-
-class ViewController: UIViewController {
-
-    override func viewDidLoad() {
-        super.viewDidLoad()
-        // Do any additional setup after loading the view.
-
-    }
-
-
-}
-

+ 157 - 0
LiveLikeGiphyChallenge/ViewControllers/Presenters/GifListPresenter.swift

@@ -0,0 +1,157 @@
+//
+//  GifListPresenter.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import Foundation
+
+
+/*!
+    GifListPresenters delegate
+ */
+protocol GifListPresenterDelegate: AnyObject {
+    
+    /// Called before making a call on an empty data
+    func presenterWillStartLoadingForEmptyData()
+    
+    /// Called when inital data is loaded
+    func presenterDidReceiveInitialData()
+    
+    /// Called when next page is loaded
+    func presenterDidReceiveAdditionalData(at indexPaths: [IndexPath])
+
+    
+}
+
+/*!
+    Presenter that loads the trending page,
+    if a search keyword is present, it uses that keyword to
+    search the Giphy library
+ */
+class GifListPresenter {
+    
+    weak var delegate: GifListPresenterDelegate?
+
+    /*!
+        Last searched term
+        On search term change, reset the pagination
+     */
+    private var lastSearchedTerm: String = "" {
+        willSet {
+            if newValue != lastSearchedTerm {
+                pagination = GifListPagination()
+            }
+        }
+    }
+    
+    /*!
+        Last pagination object
+     */
+    private var pagination: GifListPagination = GifListPagination()
+    
+    /*!
+        Array of objects to be shown
+     */
+    var gifsList: [GifObject] = []
+    
+    /*!
+        Only one pagination request should be active
+     */
+    var isLoadingNewPage = false
+    
+    /*!
+        If a search query is present, search data is fetched.
+        If there is no search query, trending data is fetched.
+     */
+    func getData(query: String) {
+        
+        self.lastSearchedTerm = query
+        
+        if lastSearchedTerm.isEmpty {
+            self.fetchTrendingData()
+        } else {
+            self.fetchSearchData(query: query)
+        }
+        
+    }
+    
+    /// Trending data call
+    private func fetchTrendingData() {
+        
+        self.prepareForFetch()
+        
+        GiphyApiManager.shared.getTrendingGifs(offset: pagination.nextOffset) { [weak self] result in
+            
+            switch result {
+                
+            case .success(let listResponse):
+                self?.didReceiveSuccessfulListResponse(listResponse: listResponse)
+                
+            case .failure(let error):
+                print(error)
+            }
+            
+            self?.isLoadingNewPage = false
+
+        }
+        
+    }
+    
+    /// Search data called
+    private func fetchSearchData(query: String) {
+        
+        self.prepareForFetch()
+
+        GiphyApiManager.shared.getSearchGifs(offset: pagination.nextOffset, query: query) { [weak self] result in
+            
+            switch result {
+                
+            case .success(let listResponse):
+                self?.didReceiveSuccessfulListResponse(listResponse: listResponse)
+
+            case .failure(let error):
+                print(error)
+            }
+
+            self?.isLoadingNewPage = false
+        }
+        
+    }
+    
+    /// Prepares self before making a call
+    private func prepareForFetch() {
+        
+
+        if self.gifsList.count == 0 {
+            self.delegate?.presenterWillStartLoadingForEmptyData()
+        }
+
+        GiphyApiManager.shared.cancelLastSearchableTask()
+        
+        if pagination.count + pagination.offset != 0 {
+            isLoadingNewPage = true
+        }
+
+    }
+
+    private func didReceiveSuccessfulListResponse(listResponse: GiphyListResponse) {
+        
+        let offset = listResponse.pagination.offset
+        
+        if (offset == 0) {
+            self.gifsList = listResponse.data
+            self.delegate?.presenterDidReceiveInitialData()
+        } else {
+            let indexPaths = (offset..<(offset + listResponse.data.count)).map({IndexPath(row: $0, section: 0)})
+            self.gifsList += listResponse.data
+            self.delegate?.presenterDidReceiveAdditionalData(at: indexPaths)
+        }
+        
+        self.pagination = listResponse.pagination
+        
+    }
+
+    
+}

+ 185 - 0
LiveLikeGiphyChallenge/ViewControllers/Views/GifListViewController.swift

@@ -0,0 +1,185 @@
+//
+//  GifListViewController.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Ljupco Nastevski on 30.1.22.
+//
+
+import UIKit
+
+class GifListViewController: UIViewController {
+
+    private var presenter: GifListPresenter = GifListPresenter()
+    
+    private lazy var collectionView: UICollectionView = {
+        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: GifListCollectionViewFlowLayout())
+        collectionView.register(GifCollectionViewCell.self, forCellWithReuseIdentifier: GifCollectionViewCell.reuseIdentifier)
+        collectionView.keyboardDismissMode = .onDrag
+        return collectionView
+    }()
+    
+    private lazy var searchBar: UISearchBar = {
+        let sb = UISearchBar()
+        sb.delegate = self
+        sb.placeholder = "search library..."
+        return sb
+    }()
+    
+    /// Indicator view that we expect to show when no data is present
+    private lazy var activityIndicator: UIActivityIndicatorView = {
+        let aIndicator = UIActivityIndicatorView()
+        aIndicator.hidesWhenStopped = true
+        return aIndicator
+    }()
+    
+    /// Delay the search by couple of ms so the web devs will like us
+    private var delayedWorkItem: DispatchWorkItem?
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        self.setupUI()
+        self.setupSelf()
+        self.presenter.getData(query: "")
+    }
+    
+    private func setupUI() {
+        
+        self.view.addSubview(self.collectionView)
+        self.view.addSubview(self.activityIndicator)
+        
+        self.initialConstraintSetup()
+        
+    }
+    
+    private func setupSelf() {
+        
+        self.navigationItem.titleView = self.searchBar
+                
+        presenter.delegate = self
+        
+        collectionView.delegate = self
+        collectionView.dataSource = self
+        collectionView.prefetchDataSource = self
+        
+    }
+
+}
+
+// MARK: - Constraints
+
+extension GifListViewController {
+    
+    /*!
+        Initial constraints setup
+     */
+    private func initialConstraintSetup() {
+        
+        self.collectionView.translatesAutoresizingMaskIntoConstraints = false
+        self.activityIndicator.translatesAutoresizingMaskIntoConstraints = false
+
+        NSLayoutConstraint.activate([
+            
+            self.collectionView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
+            self.collectionView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor),
+            self.collectionView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor),
+            self.collectionView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor),
+            
+            self.activityIndicator.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
+            self.activityIndicator.centerYAnchor.constraint(equalTo: self.view.centerYAnchor)
+            
+        ])
+
+    }
+}
+
+// MARK: - Presenter delegate
+
+extension GifListViewController: GifListPresenterDelegate {
+    
+    func presenterDidReceiveInitialData() {
+        
+        DispatchQueue.main.async {
+            
+            self.collectionView.setContentOffset(CGPoint(x: 0, y: self.collectionView.safeAreaInsets.top), animated: false)
+            self.collectionView.reloadData()
+            
+            if self.activityIndicator.isAnimating == true {
+                self.activityIndicator.stopAnimating()
+            }
+            
+        }
+
+    }
+    
+    
+    func presenterDidReceiveAdditionalData(at indexPaths: [IndexPath]) {
+        
+        DispatchQueue.main.async {
+            self.collectionView.insertItems(at: indexPaths)
+        }
+
+    }
+    
+    
+    func presenterWillStartLoadingForEmptyData() {
+        
+        DispatchQueue.main.async {
+            self.activityIndicator.startAnimating()
+        }
+        
+    }
+    
+}
+
+
+// MARK: - SearchBar delegate
+
+extension GifListViewController: UISearchBarDelegate {
+    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
+        
+        self.delayedWorkItem?.cancel()
+        
+        delayedWorkItem = DispatchWorkItem { [weak self] in
+            self?.presenter.getData(query: searchText)
+        }
+        
+        DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.2, execute: delayedWorkItem!)
+    }
+}
+
+
+// MARK: - CollectionView delegate and data source
+
+extension GifListViewController: UICollectionViewDataSource, UICollectionViewDelegate {
+    
+    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+        return presenter.gifsList.count
+    }
+    
+    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+        
+        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: GifCollectionViewCell.reuseIdentifier, for: indexPath) as! GifCollectionViewCell
+        cell.gif = presenter.gifsList[indexPath.row]
+        return cell
+    }
+        
+}
+
+extension GifListViewController: UICollectionViewDataSourcePrefetching {
+    
+    func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
+        
+        guard presenter.isLoadingNewPage == false else { return }
+        
+        for indexPath in indexPaths {
+            if indexPath.row > presenter.gifsList.count - Constants.GiphyPaginationLimit/2 {
+                delayedWorkItem?.cancel()
+                presenter.getData(query: self.searchBar.text ?? "")
+                break
+            }
+        }
+        
+    }
+    
+    
+}