Browse Source

Implement trending and searched gifs from Giphy API

Arbnor Tefiki 3 years ago
parent
commit
08799ed3ec

+ 49 - 9
LiveLikeGiphyChallenge.xcodeproj/project.pbxproj

@@ -9,12 +9,20 @@
 /* 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 */; };
+		27076083276409C000064F59 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27076082276409C000064F59 /* MainViewController.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 */; };
+		AE57E35127AFEE3F004BD2B4 /* GiphyCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE57E35027AFEE3F004BD2B4 /* GiphyCollectionViewCell.swift */; };
+		AE57E35327AFF02D004BD2B4 /* GiphyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE57E35227AFF02D004BD2B4 /* GiphyViewModel.swift */; };
+		AE57E35627AFFCE4004BD2B4 /* APIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE57E35527AFFCE4004BD2B4 /* APIManager.swift */; };
+		AE57E35827AFFEC7004BD2B4 /* GiphyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE57E35727AFFEC7004BD2B4 /* GiphyModel.swift */; };
+		AE57E35A27B01693004BD2B4 /* GiphyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE57E35927B01692004BD2B4 /* GiphyRequest.swift */; };
+		AE57E35C27B02A58004BD2B4 /* GifImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE57E35B27B02A57004BD2B4 /* GifImage.swift */; };
+		AE57E35E27B043F5004BD2B4 /* CachedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE57E35D27B043F5004BD2B4 /* CachedImageView.swift */; };
+		AE57E36027B079F4004BD2B4 /* ActivityIndicatorCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE57E35F27B079F4004BD2B4 /* ActivityIndicatorCollectionViewCell.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -38,7 +46,7 @@
 		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>"; };
+		27076082276409C000064F59 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.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 +55,14 @@
 		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>"; };
+		AE57E35027AFEE3F004BD2B4 /* GiphyCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyCollectionViewCell.swift; sourceTree = "<group>"; };
+		AE57E35227AFF02D004BD2B4 /* GiphyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyViewModel.swift; sourceTree = "<group>"; };
+		AE57E35527AFFCE4004BD2B4 /* APIManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = APIManager.swift; path = LiveLikeGiphyChallenge/APIManager.swift; sourceTree = SOURCE_ROOT; };
+		AE57E35727AFFEC7004BD2B4 /* GiphyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyModel.swift; sourceTree = "<group>"; };
+		AE57E35927B01692004BD2B4 /* GiphyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyRequest.swift; sourceTree = "<group>"; };
+		AE57E35B27B02A57004BD2B4 /* GifImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifImage.swift; sourceTree = "<group>"; };
+		AE57E35D27B043F5004BD2B4 /* CachedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedImageView.swift; sourceTree = "<group>"; };
+		AE57E35F27B079F4004BD2B4 /* ActivityIndicatorCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorCollectionViewCell.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -97,12 +113,18 @@
 		2707607D276409C000064F59 /* LiveLikeGiphyChallenge */ = {
 			isa = PBXGroup;
 			children = (
+				AE57E35427AFFC7C004BD2B4 /* Resources */,
 				2707607E276409C000064F59 /* AppDelegate.swift */,
 				27076080276409C000064F59 /* SceneDelegate.swift */,
-				27076082276409C000064F59 /* ViewController.swift */,
-				27076087276409C200064F59 /* Assets.xcassets */,
-				27076089276409C200064F59 /* LaunchScreen.storyboard */,
-				2707608C276409C200064F59 /* Info.plist */,
+				27076082276409C000064F59 /* MainViewController.swift */,
+				AE57E35027AFEE3F004BD2B4 /* GiphyCollectionViewCell.swift */,
+				AE57E35F27B079F4004BD2B4 /* ActivityIndicatorCollectionViewCell.swift */,
+				AE57E35227AFF02D004BD2B4 /* GiphyViewModel.swift */,
+				AE57E35727AFFEC7004BD2B4 /* GiphyModel.swift */,
+				AE57E35927B01692004BD2B4 /* GiphyRequest.swift */,
+				AE57E35527AFFCE4004BD2B4 /* APIManager.swift */,
+				AE57E35B27B02A57004BD2B4 /* GifImage.swift */,
+				AE57E35D27B043F5004BD2B4 /* CachedImageView.swift */,
 			);
 			path = LiveLikeGiphyChallenge;
 			sourceTree = "<group>";
@@ -124,6 +146,16 @@
 			path = LiveLikeGiphyChallengeUITests;
 			sourceTree = "<group>";
 		};
+		AE57E35427AFFC7C004BD2B4 /* Resources */ = {
+			isa = PBXGroup;
+			children = (
+				27076087276409C200064F59 /* Assets.xcassets */,
+				27076089276409C200064F59 /* LaunchScreen.storyboard */,
+				2707608C276409C200064F59 /* Info.plist */,
+			);
+			path = Resources;
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -254,9 +286,17 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
-				27076083276409C000064F59 /* ViewController.swift in Sources */,
+				AE57E35627AFFCE4004BD2B4 /* APIManager.swift in Sources */,
+				AE57E36027B079F4004BD2B4 /* ActivityIndicatorCollectionViewCell.swift in Sources */,
+				AE57E35127AFEE3F004BD2B4 /* GiphyCollectionViewCell.swift in Sources */,
+				AE57E35E27B043F5004BD2B4 /* CachedImageView.swift in Sources */,
+				27076083276409C000064F59 /* MainViewController.swift in Sources */,
+				AE57E35327AFF02D004BD2B4 /* GiphyViewModel.swift in Sources */,
 				2707607F276409C000064F59 /* AppDelegate.swift in Sources */,
+				AE57E35827AFFEC7004BD2B4 /* GiphyModel.swift in Sources */,
 				27076081276409C000064F59 /* SceneDelegate.swift in Sources */,
+				AE57E35C27B02A58004BD2B4 /* GifImage.swift in Sources */,
+				AE57E35A27B01693004BD2B4 /* GiphyRequest.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -428,7 +468,7 @@
 				CODE_SIGN_STYLE = Automatic;
 				CURRENT_PROJECT_VERSION = 1;
 				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_FILE = LiveLikeGiphyChallenge/Info.plist;
+				INFOPLIST_FILE = LiveLikeGiphyChallenge/Resources/Info.plist;
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown";
@@ -455,7 +495,7 @@
 				CODE_SIGN_STYLE = Automatic;
 				CURRENT_PROJECT_VERSION = 1;
 				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_FILE = LiveLikeGiphyChallenge/Info.plist;
+				INFOPLIST_FILE = LiveLikeGiphyChallenge/Resources/Info.plist;
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown";

+ 49 - 0
LiveLikeGiphyChallenge/APIManager.swift

@@ -0,0 +1,49 @@
+//
+//  APIManager.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Arbnor Tefiki on 6.2.22.
+//
+
+import Foundation
+
+class APIManager {
+  private static let giphyApiKey = "krF6wnSax3bZYT1kAAEfLeB1UwN7ZsJM"
+  typealias GiphyResponse = (_ response: GiphyModel?) -> Void
+
+  static func fetchTrendingGifs(request: GiphyRequest, completion: @escaping GiphyResponse) {
+    var request = URLRequest(url: urlBuilder(request: request))
+    request.httpMethod = "GET"
+    URLSession.shared.dataTask(with: request) { (data, response, error) in
+      if let err = error {
+        print("Error fetching from Giphy: ", err.localizedDescription)
+        completion(nil)
+      }
+      do {
+        DispatchQueue.main.async {
+          guard let data = data else {
+            completion(nil)
+            return
+          }
+
+          let object = try? JSONDecoder().decode(GiphyModel.self, from: data)
+          completion(object)
+        }
+      }
+    }.resume()
+  }
+
+  static func urlBuilder(request: GiphyRequest) -> URL {
+    var components = URLComponents()
+    components.scheme = "https"
+    components.host = "api.giphy.com"
+    components.path = "/v1/gifs/\(request.urlPath)"
+    components.queryItems = [
+      URLQueryItem(name: "api_key", value: giphyApiKey),
+      URLQueryItem(name: "q", value: request.searchTerm),
+      URLQueryItem(name: "limit", value: String(request.limit)),
+      URLQueryItem(name: "offset", value: String(request.offset))
+    ]
+    return components.url!
+  }
+}

+ 31 - 0
LiveLikeGiphyChallenge/ActivityIndicatorCollectionViewCell.swift

@@ -0,0 +1,31 @@
+//
+//  ActivityIndicatorCollectionViewCell.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Arbnor Tefiki on 6.2.22.
+//
+
+import UIKit
+
+class ActivityIndicatorCollectionViewCell: UICollectionViewCell {
+
+  lazy var spinner = UIActivityIndicatorView(style: .large)
+
+  override init(frame: CGRect) {
+    super.init(frame: frame)
+    commonInit()
+  }
+
+  required init?(coder: NSCoder) {
+    super.init(coder: coder)
+    commonInit()
+  }
+
+  private func commonInit() {
+    spinner.translatesAutoresizingMaskIntoConstraints = false
+    contentView.addSubview(spinner)
+    spinner.centerXAnchor.constraint(equalTo: contentView.centerXAnchor).isActive = true
+    spinner.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true
+  }
+}
+

+ 41 - 0
LiveLikeGiphyChallenge/CachedImageView.swift

@@ -0,0 +1,41 @@
+//
+//  CachedImageView.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Arbnor Tefiki on 6.2.22.
+//
+
+import UIKit
+
+var cachedImages = NSCache<NSString, UIImage>()
+
+class CachedImageView: UIImageView {
+  private var currentURL: NSString?
+
+  func loadAsyncImage(url: String, placeholder: UIImage?) {
+    let imageURL = url as NSString
+    if let cachedImage = cachedImages.object(forKey: imageURL) {
+      image = cachedImage
+      return
+    }
+    image = placeholder
+    currentURL = imageURL
+    guard let requestURL = URL(string: url) else { image = placeholder; return }
+    URLSession.shared.dataTask(with: requestURL) { (data, response, error) in
+      DispatchQueue.main.async { [weak self] in
+        guard let imageData = data, error == nil else {
+          self?.image = placeholder
+          return
+        }
+        if self?.currentURL == imageURL {
+          if let imageToShow = UIImage.gifImageWithData(imageData) {
+            cachedImages.setObject(imageToShow, forKey: imageURL)
+            self?.image = imageToShow
+          } else {
+            self?.image = placeholder
+          }
+        }
+      }
+    }.resume()
+  }
+}

+ 179 - 0
LiveLikeGiphyChallenge/GifImage.swift

@@ -0,0 +1,179 @@
+//
+//  iOSDevCenters+GIF.swift
+//  GIF-Swift
+//
+//  Created by iOSDevCenters on 11/12/15.
+//  Copyright © 2016 iOSDevCenters. All rights reserved.
+//
+import UIKit
+import ImageIO
+// FIXME: comparison operators with optionals were removed from the Swift Standard Libary.
+// Consider refactoring the code to use the non-optional operators.
+fileprivate func < <T : Comparable>(lhs: T?, rhs: T?) -> Bool {
+  switch (lhs, rhs) {
+  case let (l?, r?):
+    return l < r
+  case (nil, _?):
+    return true
+  default:
+    return false
+  }
+}
+
+extension UIImage {
+
+  public class func gifImageWithData(_ data: Data) -> UIImage? {
+    guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
+      print("image doesn't exist")
+      return nil
+    }
+
+    return UIImage.animatedImageWithSource(source)
+  }
+
+  public class func gifImageWithURL(_ gifUrl:String) -> UIImage? {
+    guard let bundleURL:URL = URL(string: gifUrl)
+    else {
+      print("image named \"\(gifUrl)\" doesn't exist")
+      return nil
+    }
+    guard let imageData = try? Data(contentsOf: bundleURL) else {
+      print("image named \"\(gifUrl)\" into NSData")
+      return nil
+    }
+
+    return gifImageWithData(imageData)
+  }
+
+  public class func gifImageWithName(_ name: String) -> UIImage? {
+    guard let bundleURL = Bundle.main
+            .url(forResource: name, withExtension: "gif") else {
+              print("SwiftGif: This image named \"\(name)\" does not exist")
+              return nil
+            }
+    guard let imageData = try? Data(contentsOf: bundleURL) else {
+      print("SwiftGif: Cannot turn image named \"\(name)\" into NSData")
+      return nil
+    }
+
+    return gifImageWithData(imageData)
+  }
+
+  class func delayForImageAtIndex(_ index: Int, source: CGImageSource!) -> Double {
+    var delay = 0.1
+
+    let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil)
+    let gifProperties: CFDictionary = unsafeBitCast(
+      CFDictionaryGetValue(cfProperties,
+                           Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque()),
+      to: CFDictionary.self)
+
+    var delayObject: AnyObject = unsafeBitCast(
+      CFDictionaryGetValue(gifProperties,
+                           Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()),
+      to: AnyObject.self)
+    if delayObject.doubleValue == 0 {
+      delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties,
+                                                       Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), to: AnyObject.self)
+    }
+
+    delay = delayObject as! Double
+
+    if delay < 0.1 {
+      delay = 0.1
+    }
+
+    return delay
+  }
+
+  class func gcdForPair(_ a: Int?, _ b: Int?) -> Int {
+    var a = a
+    var b = b
+    if b == nil || a == nil {
+      if b != nil {
+        return b!
+      } else if a != nil {
+        return a!
+      } else {
+        return 0
+      }
+    }
+
+    if a < b {
+      let c = a
+      a = b
+      b = c
+    }
+
+    var rest: Int
+    while true {
+      rest = a! % b!
+
+      if rest == 0 {
+        return b!
+      } else {
+        a = b
+        b = rest
+      }
+    }
+  }
+
+  class func gcdForArray(_ array: Array<Int>) -> Int {
+    if array.isEmpty {
+      return 1
+    }
+
+    var gcd = array[0]
+
+    for val in array {
+      gcd = UIImage.gcdForPair(val, gcd)
+    }
+
+    return gcd
+  }
+
+  class func animatedImageWithSource(_ source: CGImageSource) -> UIImage? {
+    let count = CGImageSourceGetCount(source)
+    var images = [CGImage]()
+    var delays = [Int]()
+
+    for i in 0..<count {
+      if let image = CGImageSourceCreateImageAtIndex(source, i, nil) {
+        images.append(image)
+      }
+
+      let delaySeconds = UIImage.delayForImageAtIndex(Int(i),
+                                                      source: source)
+      delays.append(Int(delaySeconds * 1000.0)) // Seconds to ms
+    }
+
+    let duration: Int = {
+      var sum = 0
+
+      for val: Int in delays {
+        sum += val
+      }
+
+      return sum
+    }()
+
+    let gcd = gcdForArray(delays)
+    var frames = [UIImage]()
+
+    var frame: UIImage
+    var frameCount: Int
+    for i in 0..<count {
+      frame = UIImage(cgImage: images[Int(i)])
+      frameCount = Int(delays[Int(i)] / gcd)
+
+      for _ in 0..<frameCount {
+        frames.append(frame)
+      }
+    }
+
+    let animation = UIImage.animatedImage(with: frames,
+                                          duration: Double(duration) / 1000.0)
+
+    return animation
+  }
+}

+ 48 - 0
LiveLikeGiphyChallenge/GiphyCollectionViewCell.swift

@@ -0,0 +1,48 @@
+//
+//  GiphyCollectionViewCell.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Arbnor Tefiki on 6.2.22.
+//
+
+import UIKit
+
+class GiphyCollectionViewCell: UICollectionViewCell {
+
+  fileprivate let imageView: CachedImageView = {
+    let imageView = CachedImageView()
+    imageView.contentMode = .scaleAspectFill
+    imageView.clipsToBounds = true
+    imageView.translatesAutoresizingMaskIntoConstraints = false
+    return imageView
+  }()
+
+  override init(frame: CGRect) {
+    super.init(frame: frame)
+    commonInit()
+  }
+
+  required init?(coder: NSCoder) {
+    super.init(coder: coder)
+    commonInit()
+  }
+
+  private func commonInit() {
+    contentView.addSubview(imageView)
+    setupConstraints()
+  }
+
+  func setupConstraints() {
+    NSLayoutConstraint.activate([
+      imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
+      imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
+      imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+      imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
+    ])
+  }
+
+  func setupCell(url: String) {
+    self.imageView.loadAsyncImage(url: url, placeholder: UIImage(named: "default_image"))
+  }
+
+}

+ 57 - 0
LiveLikeGiphyChallenge/GiphyModel.swift

@@ -0,0 +1,57 @@
+//
+//  GiphyModel.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Arbnor Tefiki on 6.2.22.
+//
+
+import Foundation
+
+struct GiphyModel: Decodable {
+  var gifs: [Gif]
+  var pagination: Pagination?
+  var meta: Meta?
+
+  enum CodingKeys: String, CodingKey {
+    case gifs = "data"
+    case pagination = "pagination"
+    case meta = "meta"
+  }
+}
+
+struct Gif: Decodable {
+  var gifSources: GifImages
+  enum CodingKeys: String, CodingKey {
+    case gifSources = "images"
+  }
+
+  func getGifURL() -> String{
+    return gifSources.fixedHeight.url
+  }
+}
+
+struct GifImages: Decodable {
+  var fixedHeight: GiphyContent
+  enum CodingKeys: String, CodingKey {
+    case fixedHeight = "fixed_height"
+  }
+}
+
+struct GiphyContent: Decodable {
+  var url: String
+}
+
+struct Pagination: Decodable {
+  var offset: Int?
+  var totalCount: Int?
+  var count: Int?
+
+  enum CodingKeys: String, CodingKey {
+    case totalCount = "total_count"
+  }
+}
+
+struct Meta: Decodable {
+  var msg: String
+  var status: Int
+}

+ 26 - 0
LiveLikeGiphyChallenge/GiphyRequest.swift

@@ -0,0 +1,26 @@
+//
+//  GiphyRequest.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Arbnor Tefiki on 6.2.22.
+//
+
+import Foundation
+
+struct GiphyRequest {
+  enum FetchType: String {
+    case search
+    case trending
+  }
+
+  let limit: Int = 20
+  var searchTerm: String
+  var requestType: FetchType
+  var page: Int
+  var offset: Int {
+    return page * limit
+  }
+  var urlPath: String {
+    return requestType.rawValue
+  }
+}

+ 77 - 0
LiveLikeGiphyChallenge/GiphyViewModel.swift

@@ -0,0 +1,77 @@
+//
+//  GiphyViewModel.swift
+//  LiveLikeGiphyChallenge
+//
+//  Created by Arbnor Tefiki on 6.2.22.
+//
+
+import Foundation
+
+class GiphyViewModel {
+  typealias FetchCompleted = (_ finish: Bool) -> Void
+
+  var searchTerm: String = "" {
+    didSet {
+      fetchGiphyImages(resetSearch: true)
+    }
+  }
+  var trendingImages: [String] = []
+  var searchedImages: [String] = []
+  var items: [String] { return isSearchActive ? searchedImages : trendingImages }
+
+  var canLoadList: Bool = true
+  var isLoadingList: Bool = false
+  var page = 0
+  var didFinishLoadingData: FetchCompleted?
+  var isSearchActive: Bool {
+    return !searchTerm.isEmpty
+  }
+
+  func fetchGiphyImages(resetSearch: Bool = false) {
+    isLoadingList = true
+    let request = GiphyRequest(searchTerm: searchTerm, requestType: fetchType, page: page)
+    APIManager.fetchTrendingGifs(request: request) { response in
+      self.isLoadingList = false
+      guard let response = response, response.meta?.msg == "OK" else {
+        return
+      }
+      if resetSearch {
+        self.resetSearch()
+      }
+      self.loadData(response: response)
+    }
+  }
+
+  func loadData(response: GiphyModel) {
+    let allGifUrls = response.gifs.map({ $0.getGifURL() })
+
+    if fetchType == .trending {
+      trendingImages.append(contentsOf: allGifUrls)
+    } else {
+      searchedImages.append(contentsOf: allGifUrls)
+    }
+    canLoadList = response.pagination?.totalCount ?? 0 > items.count
+    if canLoadList {
+      page += 1
+    }
+    didFinishLoadingData?(true)
+  }
+
+  func tryToLoadMore(row: Int) {
+    if items.count - 1 == row && canLoadList && !isLoadingList {
+      fetchGiphyImages()
+    }
+  }
+
+  private var fetchType: GiphyRequest.FetchType {
+    return isSearchActive ? .search : .trending
+  }
+
+  func resetSearch() {
+    page = 0
+    canLoadList = false
+    trendingImages.removeAll()
+    searchedImages.removeAll()
+  }
+
+}

+ 129 - 0
LiveLikeGiphyChallenge/MainViewController.swift

@@ -0,0 +1,129 @@
+//
+//  MainViewController.swift
+//  LiveLikeGiphyChallenge
+//
+//
+
+import UIKit
+
+class MainViewController: UIViewController {
+  static private let cellSpacing: CGFloat = 10.0
+  private let viewModel = GiphyViewModel()
+
+  fileprivate let stackView: UIStackView = {
+    let stackView = UIStackView()
+    stackView.distribution = .fill
+    stackView.axis = .vertical
+    stackView.spacing = cellSpacing
+    stackView.translatesAutoresizingMaskIntoConstraints = false
+    return stackView
+  }()
+
+  fileprivate let searchBar: UISearchBar = {
+    let searchBar = UISearchBar(frame: .zero)
+    searchBar.searchTextField.placeholder = "Search the gifs you love from Giphy"
+    searchBar.returnKeyType = .search
+    searchBar.translatesAutoresizingMaskIntoConstraints = false
+    return searchBar
+  }()
+
+  fileprivate let collectionView: UICollectionView = {
+    let layout = UICollectionViewFlowLayout()
+    layout.minimumLineSpacing = cellSpacing
+    layout.minimumInteritemSpacing = cellSpacing
+    let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
+    collectionView.register(GiphyCollectionViewCell.self, forCellWithReuseIdentifier: "GiphyCollectionViewCell")
+    collectionView.register(ActivityIndicatorCollectionViewCell.self, forCellWithReuseIdentifier: "ActivityIndicatorCollectionViewCell")
+    collectionView.translatesAutoresizingMaskIntoConstraints = false
+    return collectionView
+  }()
+
+  override func viewDidLoad() {
+    super.viewDidLoad()
+    view.backgroundColor = .white
+    setupStackView()
+    setupSearchBar()
+    setupCollectionView()
+    setupViewModel()
+  }
+
+  func setupStackView() {
+    view.addSubview(stackView)
+    stackView.addArrangedSubview(searchBar)
+    stackView.addArrangedSubview(collectionView)
+
+    NSLayoutConstraint.activate([
+      stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
+      stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 10),
+      stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
+      stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10)
+    ])
+  }
+
+  func setupCollectionView() {
+    collectionView.delegate = self
+    collectionView.dataSource = self
+  }
+
+  func setupSearchBar() {
+    searchBar.delegate = self
+    searchBar.heightAnchor.constraint(equalToConstant: 50).isActive = true
+  }
+
+  func setupViewModel() {
+    viewModel.didFinishLoadingData = { [weak self] success in
+      self?.collectionView.reloadData()
+    }
+    viewModel.fetchGiphyImages()
+  }
+
+}
+
+extension MainViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
+  func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+    return viewModel.items.count
+  }
+
+  func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+    if !viewModel.isLoadingList {
+      if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "GiphyCollectionViewCell", for: indexPath) as? GiphyCollectionViewCell {
+        let url = viewModel.items[indexPath.row]
+        cell.setupCell(url: url)
+        return cell
+      }
+    } else {
+      if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ActivityIndicatorCollectionViewCell", for: indexPath) as? ActivityIndicatorCollectionViewCell {
+        cell.spinner.startAnimating()
+        return cell
+      }
+    }
+    return UICollectionViewCell()
+  }
+
+  func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
+    let itemsPerRow: CGFloat = UIDevice.current.orientation.isLandscape ? 4 : 2
+    let cellSize = collectionView.frame.width / itemsPerRow - MainViewController.cellSpacing
+    return CGSize(width: cellSize, height: cellSize)
+  }
+
+  func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
+    viewModel.tryToLoadMore(row: indexPath.row)
+  }
+
+}
+
+extension MainViewController: UISearchBarDelegate {
+  func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
+    scrollToTop()
+    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+      self.viewModel.searchTerm = searchText
+    }
+  }
+
+  func scrollToTop() {
+    if !viewModel.items.isEmpty {
+      collectionView.scrollToItem(at: IndexPath(item: 0, section: 0), at: .top, animated: true)
+    }
+  }
+}
+

+ 0 - 0
LiveLikeGiphyChallenge/Assets.xcassets/AccentColor.colorset/Contents.json → LiveLikeGiphyChallenge/Resources/Assets.xcassets/AccentColor.colorset/Contents.json


+ 0 - 0
LiveLikeGiphyChallenge/Assets.xcassets/AppIcon.appiconset/Contents.json → LiveLikeGiphyChallenge/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json


+ 0 - 0
LiveLikeGiphyChallenge/Assets.xcassets/Contents.json → LiveLikeGiphyChallenge/Resources/Assets.xcassets/Contents.json


+ 21 - 0
LiveLikeGiphyChallenge/Resources/Assets.xcassets/default_image.imageset/Contents.json

@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "default.jpeg",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
LiveLikeGiphyChallenge/Resources/Assets.xcassets/default_image.imageset/default.jpeg


+ 0 - 0
LiveLikeGiphyChallenge/Base.lproj/LaunchScreen.storyboard → LiveLikeGiphyChallenge/Resources/Base.lproj/LaunchScreen.storyboard


+ 0 - 0
LiveLikeGiphyChallenge/Info.plist → LiveLikeGiphyChallenge/Resources/Info.plist


+ 42 - 43
LiveLikeGiphyChallenge/SceneDelegate.swift

@@ -8,49 +8,48 @@ import UIKit
 
 class SceneDelegate: UIResponder, UIWindowSceneDelegate {
 
-    var window: UIWindow?
-
-
-    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
-        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
-        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
-        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
-        guard let _ = (scene as? UIWindowScene) else { return }
-        
-        guard let windowScene = (scene as? UIWindowScene) else { return }
-                window = UIWindow(frame: windowScene.coordinateSpace.bounds)
-                window?.windowScene = windowScene
-                window?.rootViewController = ViewController()
-                window?.makeKeyAndVisible()
-    }
-
-    func sceneDidDisconnect(_ scene: UIScene) {
-        // Called as the scene is being released by the system.
-        // This occurs shortly after the scene enters the background, or when its session is discarded.
-        // Release any resources associated with this scene that can be re-created the next time the scene connects.
-        // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
-    }
-
-    func sceneDidBecomeActive(_ scene: UIScene) {
-        // Called when the scene has moved from an inactive state to an active state.
-        // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
-    }
-
-    func sceneWillResignActive(_ scene: UIScene) {
-        // Called when the scene will move from an active state to an inactive state.
-        // This may occur due to temporary interruptions (ex. an incoming phone call).
-    }
-
-    func sceneWillEnterForeground(_ scene: UIScene) {
-        // Called as the scene transitions from the background to the foreground.
-        // Use this method to undo the changes made on entering the background.
-    }
-
-    func sceneDidEnterBackground(_ scene: UIScene) {
-        // Called as the scene transitions from the foreground to the background.
-        // Use this method to save data, release shared resources, and store enough scene-specific state information
-        // to restore the scene back to its current state.
-    }
+  var window: UIWindow?
+
+  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
+    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
+    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
+    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
+    guard let _ = (scene as? UIWindowScene) else { return }
+
+    guard let windowScene = (scene as? UIWindowScene) else { return }
+    window = UIWindow(frame: windowScene.coordinateSpace.bounds)
+    window?.windowScene = windowScene
+    window?.rootViewController = MainViewController()
+    window?.makeKeyAndVisible()
+  }
+
+  func sceneDidDisconnect(_ scene: UIScene) {
+    // Called as the scene is being released by the system.
+    // This occurs shortly after the scene enters the background, or when its session is discarded.
+    // Release any resources associated with this scene that can be re-created the next time the scene connects.
+    // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
+  }
+
+  func sceneDidBecomeActive(_ scene: UIScene) {
+    // Called when the scene has moved from an inactive state to an active state.
+    // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
+  }
+
+  func sceneWillResignActive(_ scene: UIScene) {
+    // Called when the scene will move from an active state to an inactive state.
+    // This may occur due to temporary interruptions (ex. an incoming phone call).
+  }
+
+  func sceneWillEnterForeground(_ scene: UIScene) {
+    // Called as the scene transitions from the background to the foreground.
+    // Use this method to undo the changes made on entering the background.
+  }
+
+  func sceneDidEnterBackground(_ scene: UIScene) {
+    // Called as the scene transitions from the foreground to the background.
+    // Use this method to save data, release shared resources, and store enough scene-specific state information
+    // to restore the scene back to its current state.
+  }
 
 
 }

+ 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.
-
-    }
-
-
-}
-