GiphyGIF
GiphyGIF copied to clipboard
Giphy Client App build with The Composable Architecture, Coordinator Pattern, XcodeGen, and Generic Protocol
GiphyGIF
🤖 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
- Introduction
- Features
- Installation
- Libraries
- The Composable Architecture
- Coordinator Pattern
- Dependency Injection
- Project Structure
🦾 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
- Swift's New Concurrency
- SDWebImage
- SwiftUI
- The Composabable Architecture
- XcodeGen
- SwiftLint
- Swinject
- CoreData
- TCACoordinators
💨 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!
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 ;-)
🏛 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