Giphy Client App built with some of the interesting iOS tech such as TCA (The Composable Architecture by Point-Free), Swinject, Beautiful UI built with SwiftUI, Clean Architecture with Generic Protocol Approach, SPM Modularization and XcodeGen!
Module
Giffy
: the main app with presentation layerCommon
: domain and data layerCommonUI
: common utils and assetsCore
: generic protocol for DataSource and Interactor
- Introduction
- Features
- Installation
- Libraries
- The Composable Architecture
- Dependency Injection
- Project Structure
- Sharing, Copy-Pasting, and AirDropping GIFs and Stickers
- Search GIFs from various sources (Giphy and Tenor
- 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
With the greatness of XcodeGen you can simply execute :
xcodegen
Rate my XcodeGen setup!
- Swift's New Concurrency
- SDWebImage
- SwiftUI
- The Composable Architecture
- XcodeGen
- SwiftLint
- Swinject
- CoreData
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
NavigationView {
ZStack {
switch viewStore.state {
case .home:
HomeView(
store: store.scope(
state: \.home,
action: \.home
)
)
case .search:
SearchView(
store: store.scope(
state: \.search,
action: \.search
)
)
}
. . . .
}
}
}
}
}
"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
Here i'm using Swinject for Dependency Injection
import Swinject
class Injection {
static let shared = Injection()
private let container = Container()
init() {
registerSearchFeature()
}
. . . .
private func registerSearchFeature() {
container.register(SearchInteractor.self) { [unowned self] _ in
Interactor(repository: self.resolve())
}
container.register(SearchGiphyRepository<SearchRemoteDataSource>.self) { [unowned self] _ in
SearchGiphyRepository(remoteDataSource: self.resolve())
}
container.register(SearchRemoteDataSource.self) { _ in
SearchRemoteDataSource()
}
}
public static func resolve<T>() -> T {
Injection().resolve()
}
public static func resolve<T, A>(argument: A) -> T {
Injection().resolve(argument: argument)
}
public static func resolve<T>(name: String) -> T {
Injection().resolve(name: name)
}
private func resolve<T>() -> T {
guard let result = container.resolve(T.self) else {
fatalError("This type is not registered: \(T.self)")
}
return result
}
private 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
}
private 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
}
}
Injection.resolve()
Read more about Swinject
If you like this project please support me by ;-)
Giffy
:
-
Dependency
-
App
-
Module
Home
Detail
Favorite
Search
-
**GiffyWidget**
-
**GiffyTests**
Modules
:
Common
:
Data
API
DB
DataSource
Local
Remote
Entity
Repository
Domain
Model
Mapper
CommonUI
:
Assets
Extensions
Modifier
Utils
Core
:
DataSource
Extension
Repository
UseCase