Skip to content

uwaisalqadri/GiphyGIF

Repository files navigation

GiphyGIF


A B C D

🤖 Introduction

Giphy Client App built with some of the interesting iOS tech such as TCA (The Composable Architecture by Point-Free), Swinject, Coordinator Pattern, Beautiful UI built with SwiftUI, Clean Architecture with Generic Protocol Approach, SPM Modularization and XcodeGen!

Module

  • GiphyGIF: the main app with presentation layer
  • Giphy: domain and data layer
  • Common: common utils and assets
  • Core: generic protocol for DataSource and Interactor

Table of Contents

🦾 Features

  • Sharing, Copy-Pasting, and AirDropping GIFs and Stickers
  • Search GIFs
  • Save Favorite GIFs
  • Widget, Live Activty, and Dynamic Island
  • Animations!

⚠️ This project have no concern about backward compatibility, and only support the very latest or experimental api ⚠️

💿 Installation

With the greatness of XcodeGen you can simply execute :

xcodegen

Rate my XcodeGen setup!

💡 Libraries

💨 TCA: Reducer, Action, State, and Store

Define your screen's State and Action

 public struct State: Equatable {
    public var list: [Giphy] = []
    public var errorMessage: String = ""
    public var isLoading: Bool = false
    public var isError: Bool = false
  }
  
  public enum Action {    
    case fetch(request: String)
    case removeFavorite(item: Giphy, request: String)
    
    case success(response: [Giphy])
    case failed(error: Error)
  }

Setup the Reducer

public struct FavoriteReducer: Reducer {
  
  private let useCase: FavoriteInteractor
  private let removeUseCase: RemoveFavoriteInteractor
  
  init(useCase: FavoriteInteractor, removeUseCase: RemoveFavoriteInteractor) {
    self.useCase = useCase
    self.removeUseCase = removeUseCase
  }
  
  public var body: some ReducerOf<Self> {
    Reduce<State, Action> { state, action in
      switch action {
      case .fetch(let request):
        state.isLoading = true
        return .run { send in
          do {
            let response = try await self.useCase.execute(request: request)
            await send(.success(response: response))
          } catch {
            await send(.failed(error: error))
          }
        }
        
      case .success(let data):
        state.list = data
        state.isLoading = false
        return .none
        
      case .failed:
        state.isError = true
        state.isLoading = false
        return .none
        
      case .removeFavorite(let item, let request):
        return .run { send in
          do {
            let response = try await self.removeUseCase.execute(request: item)
            await send(.fetch(request: request))
          } catch {
            await send(.failed(error: error))
          }
        }
        
      }
    }
  }
}

Composing the Reducer

struct MainTabView: View {
  let store: StoreOf<MainTabReducer>
  
  var body: some View {
    WithViewStore(store, observe: \.selectedTab) { viewStore in
      ZStack {
        switch viewStore.state {
        case .home:
          AppCoordinatorView(
            coordinator: store.scope(
              state: \.homeTab,
              action: { .homeTab($0) }
            )
          )
        case .search:
          AppCoordinatorView(
            coordinator: store.scope(
              state: \.searchTab,
              action: { .searchTab($0) }
            )
          )
        }

        VStack {
          Spacer()
          TabView(currentTab: viewStore.binding(send: MainTabReducer.Action.selectedTabChanged))
            .padding(.bottom, 20)
        }
      }
    }
  }
}

"consistent and understandable" - Point-Free

Let your Store(d) Reducer update the View

struct FavoriteView: View {
  let store: StoreOf<FavoriteReducer>
  
  var body: some View {
    WithViewStore(store, observe: { $0 }) { viewStore in
      ScrollView {
        SearchField { query in
          viewStore.send(.fetch(request: query))
        }.padding(.vertical, 20)
        
        if viewStore.state.list.isEmpty {
          FavoriteEmptyView()
            .padding(.top, 50)
        }
        
        LazyVStack {
          ForEach(viewStore.state.list, id: \.id) { item in
            GiphyItemRow(
              isFavorite: true,
              giphy: item,
              onTapRow: { giphy in
                viewStore.send(.showDetail(item: giphy))
              },
              onFavorite: { giphy in
                viewStore.send(.removeFavorite(item: giphy, request: ""))
              }
            )
            .padding(.horizontal, 20)
            .padding(.bottom, 20)
          }
        }
      }
      .padding(.horizontal, 10)
      .navigationTitle(FavoriteString.titleFavorite.localized)
      .onAppear {
        viewStore.send(.fetch(request: ""))
      }
    }
  }
}

Read more about The Composable Architecture

⚙️ Navigation Between Screens Done with Coordinator Pattern supported by TCACoodinators!

Screenshot 2023-10-17 at 7 19 53 PM
struct AppCoordinatorView: View {
  let coordinator: StoreOf<AppCoordinator>
  
  var body: some View {
    TCARouter(coordinator) { screen in
      SwitchStore(screen) { screen in
        switch screen {
        case .detail:
          CaseLet(
            /AppScreen.State.detail,
             action: AppScreen.Action.detail,
             then: DetailView.init
          )
        case .favorite:
          CaseLet(
            /AppScreen.State.favorite,
             action: AppScreen.Action.favorite,
             then: FavoriteView.init
          )
        case .home:
          CaseLet(
            /AppScreen.State.home,
             action: AppScreen.Action.home,
             then: HomeView.init
          )
        case .search:
          CaseLet(
            /AppScreen.State.search,
             action: AppScreen.Action.search,
             then: SearchView.init
          )
        }
      }
    }
  }
}
public struct AppScreen: Reducer {
  public enum State: Equatable {
    case detail(DetailReducer.State)
    case favorite(FavoriteReducer.State)
    case home(HomeReducer.State)
    case search(SearchReducer.State)
  }
  
  public enum Action {
    case detail(DetailReducer.Action)
    case favorite(FavoriteReducer.Action)
    case home(HomeReducer.Action)
    case search(SearchReducer.Action)
  }
  
  public var body: some ReducerOf<Self> {
    Scope(state: /State.detail, action: /Action.detail) {
      DetailReducer(checkUseCase: Injection.shared.resolve(), addUseCase: Injection.shared.resolve(), removeUseCase: Injection.shared.resolve())
    }
    
    Scope(state: /State.favorite, action: /Action.favorite) {
      FavoriteReducer(useCase: Injection.shared.resolve(), removeUseCase: Injection.shared.resolve())
    }
    
    Scope(state: /State.home, action: /Action.home) {
      HomeReducer(useCase: Injection.shared.resolve())
    }
    
    Scope(state: /State.search, action: /Action.search) {
      SearchReducer(useCase: Injection.shared.resolve())
    }
  }
}
public struct AppCoordinator: Reducer {
  public struct State: Equatable, IndexedRouterState {
    public static let rootHomeState = AppCoordinator.State(
      routes: [.root(.home(.init()), embedInNavigationView: true)]
    )
    
    public static let rootSearchState = AppCoordinator.State(
      routes: [.root(.search(.init()), embedInNavigationView: true)]
    )
    
    public var routes: [Route<AppScreen.State>]
  }
  
  public enum Action: IndexedRouterAction {
    case routeAction(Int, action: AppScreen.Action)
    case updateRoutes([Route<AppScreen.State>])
  }
  
  public var body: some ReducerOf<Self> {
    Reduce<State, Action> { state, action in
      switch action {
      case let .routeAction(_, action: .home(.showDetail(item))):
        state.routes.presentSheet(.detail(.init(item: item)))
        
      case .routeAction(_, action: .home(.openFavorite)):
        state.routes.push(.favorite(.init()))
        
      case let .routeAction(_, action: .search(.showDetail(item))):
        state.routes.presentSheet(.detail(.init(item: item)))
        
      case .routeAction(_, action: .search(.openFavorite)):
        state.routes.push(.favorite(.init()))
        
      case let .routeAction(_, action: .favorite(.showDetail(item))):
        state.routes.presentSheet(.detail(.init(item: item)))
        
      default:
        break
      }
      
      return .none
      
    }.forEachRoute {
      AppScreen()
    }
  }
}

🚀 Dependency Injection

Here i'm using Swinject for Dependency Injection

import Swinject

class Injection {
  static let shared = Injection()
  private let container = Container()

  init() {
    registerFavoriteFeature()
  }

  . . . .

  private func registerFavoriteFeature() {
    container.register(FavoriteView.self) { [unowned self] _ in
      FavoriteView(holder: self.resolve(), router: self.resolve(), store: self.resolve())
    }
    
    container.register(StoreOf<FavoriteReducer>.self) { [unowned self] _ in
      Store(initialState: FavoriteReducer.State()) {
        FavoriteReducer(useCase: self.resolve(), removeUseCase: self.resolve())
      }
    }

    . . . .
  }

  func resolve<T>() -> T {
    guard let result = container.resolve(T.self) else {
      fatalError("This type is not registered: \(T.self)")
    }
    return result
  }

  func resolve<T, A>(argument: A) -> T {
    guard let result = container.resolve(T.self, argument: argument) else {
      fatalError("This type is not registered: \(T.self)")
    }
    return result
  }
  func resolve<T>(name: String) -> T {
    guard let result = container.resolve(T.self, name: name) else {
      fatalError("This type is not registered: \(T.self)")
    }
    return result
  }
}

Read more about Swinject

☕️ Buy Me a Coffee

If you like this project please support me by Buy Me A Coffee ;-)

🏛 Project Structure

GiphyGIF:

  • Dependency

  • App

  • Module

    • Home
    • Detail
    • Favorite
    • Search
  • **GiphyWidget**

Modules:

Giphy:

  • Data
    • API
    • DB
    • DataSource
      • Local
      • Remote
    • Entity
    • Repository
  • Domain
    • Model
    • Mapper

Common:

  • Assets
  • Extensions
  • Modifier
  • Utils

Core:

  • DataSource
  • Extension
  • Repository
  • UseCase