This is a mastodon sample SwiftUI app implemented with the architecture of state management with normalized cache.

Overview

MastodonNormalizedCacheSample

This is a mastodon sample SwiftUI app.
This app is implemented with the architecture of state management with Normalized Cache.

Home Detail Profile

Requirements

Xcode 14 beta 1+
iOS 16.0+

Motivation

If you develop an iOS app for mastodon, for example, you need to make sure that no matter which screen you use to perform a favorite action on a post, the same post on all screens should reflect the same state. The same is true for other mutations, such as updating profile information.

To prevent data inconsistencies, a good solution is to hoist up the states that require synchronization as Global State and propagate them to each screen, rather than managing them separately on each screen.

The Single Store architecture such as Redux is well known as a method of managing Global State.

But it is an overkill architecture when most of the state to be managed is Server State like responses from the server.

Solution

In order to meet these requirements, the architecture of state management with Normalized Cache is adopted.
A GraphQL Client library such as Apollo Client and Relay provides this functionality.

Normalized Cache is that splitting the data retrieved from the server into individual objects, assign a logically unique identifier to each object, and store them in a flat data structure.

This allows, for example, in the case of the mastodon app shown in the previous example, favorite actions on a post object will be properly updated by a single uniquely managed post object, so that they can be reflected in the UI of each screen without inconsistencies.

Detail

This section describes the detailed implementation in developing a mastodon iOS app adopting the state management architecture with Normalized Cache.

First, mastodon's API is defined as REST API, not GraphQL. Therefore, it is not possible to use Normalized Cache with a library such as Apollo Client.

So this time, we will use the Core Data database to cache the data fetched from the REST API.

Core Data

First, create a CoreDataStore shared class to hold the Persistence Container for Core Data.

class CoreDataStore {
    static let shared = CoreDataStore()

    lazy private var container: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "Mastodon")
        container.loadPersistentStores { storeDescription, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
        return container
    }()
    
    var viewContext: NSManagedObjectContext {
        container.viewContext
    }

    private init() {
    }

    func makeBackgroundContext() -> NSManagedObjectContext {
        let context = container.newBackgroundContext()
        return context
    }
}

Core Data is used only for state management purposes, so applying In-memory database setting.

lazy private var container: NSPersistentContainer = {
    let container = NSPersistentContainer(name: "Mastodon")
+   guard let description = container.persistentStoreDescriptions.first else {
+       fatalError("Failed to retrieve a persistent store description.")
+   }
+   
+   description.url = URL(fileURLWithPath: "/dev/null")
    container.loadPersistentStores { storeDescription, error in
        if let error = error as NSError? {
        fatalError("Unresolved error \(error), \(error.userInfo)")
        }
    }
    return container
}()

Next, add a Core Data Model corresponding to Status, an object representing a post, and Account, an object representing a user in the mastodon.
And set up a relationship from Status to Account.

Also, to prevent duplicate of the same object, set constraints for these model's primary key.

Then set the merge policy to NSMergeByPropertyObjectTrumpMergePolicy. This will cause the data to be overwritten and saved if the same object with the primary key is saved.

class CoreDataStore {
    static let shared = CoreDataStore()

    lazy private var container: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "Mastodon")
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("Failed to retrieve a persistent store description.")
        }

        description.url = URL(fileURLWithPath: "/dev/null")
        container.loadPersistentStores { storeDescription, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
+       container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        return container
    }()
    
    var viewContext: NSManagedObjectContext {
        container.viewContext
    }

    private init() {
    }

    func makeBackgroundContext() -> NSManagedObjectContext {
        let context = container.newBackgroundContext()
+       context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        return context
    }
}

Now that the preparations around Core Data are done, create a CoreDataStatusCacheStore class to cache Status.
Execute the process of writing to Core Data using the Background Context.

class CoreDataStatusCacheStore {
    private let coreDataStore: CoreDataStore

    init(coreDataStore: CoreDataStore = .shared) {
        self.coreDataStore = coreDataStore
    }

    func store(_ status: Status) async throws {
        let context = coreDataStore.makeBackgroundContext()
        try await context.perform {
            let coreDataStatus = CoreDataStatus(context: context)
            coreDataStatus.update(with: status)
            try context.save()
        }
    }

    func store(_ statuses: [Status]) async throws {
        let context = coreDataStore.makeBackgroundContext()
        try await context.perform {
            statuses.forEach { status in
                let coreDataStatus = CoreDataStatus(context: context)
                coreDataStatus.update(with: status)
            }
            try context.save()
        }
    }
}

Let's add the implementation around change observing on Core Data.

Core Data allows you to observe changes to data on Core Data using NSFetchedResultsControllerDelegate.

So we will create a custom Publisher containing this observing process.

protocol CoreDataModel {
    associatedtype Entity: Equatable
    func toEntity() -> Entity
}

struct CoreDataModelPublisher<Model: CoreDataModel & NSManagedObject>: Publisher {
    typealias Output = [Model.Entity]
    typealias Failure = Never

    let context: NSManagedObjectContext
    let fetchRequest: NSFetchRequest<Model>

    init(
        context: NSManagedObjectContext,
        fetchRequest: NSFetchRequest<Model>
    ) {
        self.context = context
        self.fetchRequest = fetchRequest
    }

    func receive<S>(subscriber: S) where S: Subscriber, S.Input == Output, S.Failure == Failure {
        do {
            let subscription = try CoreDataModelSubscription(
                subscriber: subscriber,
                context: context,
                fetchRequest: fetchRequest
            )
            subscriber.receive(subscription: subscription)
        } catch {
            subscriber.receive(completion: .finished)
        }
    }
}

class CoreDataModelSubscription<
    S: Subscriber,
    Model: CoreDataModel & NSManagedObject
>: NSObject, NSFetchedResultsControllerDelegate, Subscription where S.Input == [Model.Entity], S.Failure == Never {
    private let controller: NSFetchedResultsController<Model>
    private let entitiesSubject: CurrentValueSubject<[Model.Entity], Never>
    private var cancellable: AnyCancellable?

    init(
        subscriber: S,
        context: NSManagedObjectContext,
        fetchRequest: NSFetchRequest<Model>
    ) throws {
        entitiesSubject = CurrentValueSubject((try context.fetch(fetchRequest)).map { $0.toEntity() })
        controller = NSFetchedResultsController(
            fetchRequest: fetchRequest,
            managedObjectContext: context,
            sectionNameKeyPath: nil,
            cacheName: nil
        )
        super.init()
        controller.delegate = self
        try controller.performFetch()
        var publisher = entitiesSubject
            .removeDuplicates()
            .eraseToAnyPublisher()
        cancellable = publisher
            .subscribe(on: DispatchQueue.global())
            .sink { entities in
                _ = subscriber.receive(entities)
            }
    }

    func request(_ demand: Subscribers.Demand) {}

    func cancel() {
        cancellable = nil
    }

    func controller(
        _ controller: NSFetchedResultsController<NSFetchRequestResult>,
        didChangeContentWith diff: CollectionDifference<NSManagedObjectID>
    ) {
        guard let models = controller.fetchedObjects as? [Model] else {
            return
        }

        entitiesSubject.send(models.map { $0.toEntity() })
    }
}

All that remains is to add a method that returns this custom Publisher into CoreDataStatusCacheStore.
This method receives CacheKey as a parameter for spefifying observed data.

The Core Data writing process was done in the Background Context, but the reading process is done in the View Context.

class CoreDataStatusCacheStore {
    ...
    func watch(by cacheKey: TimelineCacheKey) -> AnyPublisher<[Status], Never> {
        let fetchRequest = CoreDataStatus.fetchRequest()
        fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \CoreDataStatus.createdAt, ascending: false)]
        fetchRequest.predicate = NSPredicate(format: "id in %@", cacheKey.statusCacheKeys.map { $0.statusID })
        return CoreDataModelPublisher(
            context: coreDataStore.viewContext,
            fetchRequest: fetchRequest
        ).eraseToAnyPublisher()
    }

    func watch(by cacheKey: StatusCacheKey) -> AnyPublisher<Status, Never> {
        let fetchRequest = CoreDataStatus.fetchRequest()
        fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \CoreDataStatus.createdAt, ascending: false)]
        fetchRequest.fetchLimit = 1
        fetchRequest.predicate = NSPredicate(format: "id = %@", cacheKey.statusID)
        return CoreDataModelPublisher(
            context: coreDataStore.viewContext,
            fetchRequest: fetchRequest
        )
        .compactMap { $0.first }
        .eraseToAnyPublisher()
    }
}

There are two points to note here.

One is that changes in the Background Context must be merged into the View Context.
If this is not done, the NSFetchedResultsControllerDelegate that is fetching in the View Context will NOT be notified of the change.

To resolve this problem, extract the relevant changes by parsing the store’s Persistent History, then merge them into the view context. For more information on persistent history tracking, see Consuming Relevant Store Changes.

class CoreDataStore {
    static let shared = CoreDataStore()

+   private var notificationToken: NSObjectProtocol?
+   private var lastToken: NSPersistentHistoryToken?

    lazy private var container: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "Mastodon")
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("Failed to retrieve a persistent store description.")
        }

        description.url = URL(fileURLWithPath: "/dev/null")
+       description.setOption(
+           true as NSNumber,
+           forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
+       )
+       description.setOption(
+           true as NSNumber,
+           forKey: NSPersistentHistoryTrackingKey
+       )
        container.loadPersistentStores { storeDescription, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        return container
    }()

    var viewContext: NSManagedObjectContext {
        container.viewContext
    }

    private init() {
+       notificationToken = NotificationCenter.default.addObserver(forName: .NSPersistentStoreRemoteChange, object: nil, queue: nil) { note in
+           Task {
+               await self.fetchPersistentHistory()
+           }
+       }
    }

    func makeBackgroundContext() -> NSManagedObjectContext {
        let context = container.newBackgroundContext()
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        return context
    }
+
+   private func fetchPersistentHistory() async {
+       do {
+           try await fetchPersistentHistoryTransactionsAndChanges()
+       } catch {
+           print("\(error.localizedDescription)")
+       }
+   }
+
+   private func fetchPersistentHistoryTransactionsAndChanges() async throws {
+       let taskContext = makeBackgroundContext()
+       taskContext.name = "persistentHistoryContext"
+
+       try await taskContext.perform {
+           let changeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastToken)
+           let historyResult = try taskContext.execute(changeRequest) as? NSPersistentHistoryResult
+           if let history = historyResult?.result as? [NSPersistentHistoryTransaction],
+              !history.isEmpty {
+               self.mergePersistentHistoryChanges(from: history)
+               return
+           }
+           throw CoreDataError.persistentHistoryChangeError
+       }
+   }
+
+   private func mergePersistentHistoryChanges(from history: [NSPersistentHistoryTransaction]) {
+       let viewContext = viewContext
+       viewContext.perform {
+           for transaction in history {
+               viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
+               self.lastToken = transaction.token
+           }
+       }
+   }
}

Another point to note is that NSFetchedResultsControllerDelegate cannot observe relationship changes.
That is, if the username of an Account associated with a Status is changed, the NSFetchedResultsControllerDelegate will NOT receive notification of the change.

Therefore, we need to implement an additional relationship observing process.

To do so, make a CoreDataModelPublisher combinable another CoreDataModelPublisher.

struct CoreDataModelPublisher<Model: CoreDataModel & NSManagedObject>: Publisher {
    typealias Output = [Model.Entity]
    typealias Failure = Never

    let context: NSManagedObjectContext
    let fetchRequest: NSFetchRequest<Model>
+   let combinePublisher: ((AnyPublisher<[Model.Entity], Never>) -> AnyPublisher<[Model.Entity], Never>)?

    init(
        context: NSManagedObjectContext,
        fetchRequest: NSFetchRequest<Model>,
+       combinePublisher: ((AnyPublisher<[Model.Entity], Never>) -> AnyPublisher<[Model.Entity], Never>)? = nil
    ) {
        self.context = context
        self.fetchRequest = fetchRequest
+       self.combinePublisher = combinePublisher
    }

    func receive<S>(subscriber: S) where S: Subscriber, S.Input == Output, S.Failure == Failure {
        do {
            let subscription = try CoreDataModelSubscription(
                subscriber: subscriber,
                context: context,
                fetchRequest: fetchRequest,
+               combinePublisher: combinePublisher
            )
            subscriber.receive(subscription: subscription)
        } catch {
            subscriber.receive(completion: .finished)
        }
    }
}

class CoreDataModelSubscription<
    S: Subscriber,
    Model: CoreDataModel & NSManagedObject
>: NSObject, NSFetchedResultsControllerDelegate, Subscription where S.Input == [Model.Entity], S.Failure == Never {
    private let controller: NSFetchedResultsController<Model>
    private let entitiesSubject: CurrentValueSubject<[Model.Entity], Never>
    private var cancellable: AnyCancellable?

    init(
        subscriber: S,
        context: NSManagedObjectContext,
        fetchRequest: NSFetchRequest<Model>,
+       combinePublisher: ((AnyPublisher<[Model.Entity], Never>) -> AnyPublisher<[Model.Entity], Never>)?
    ) throws {
        entitiesSubject = CurrentValueSubject((try context.fetch(fetchRequest)).map { $0.toEntity() })
        controller = NSFetchedResultsController(
            fetchRequest: fetchRequest,
            managedObjectContext: context,
            sectionNameKeyPath: nil,
            cacheName: nil
        )
        super.init()
        controller.delegate = self
        try controller.performFetch()
        var publisher = entitiesSubject
            .removeDuplicates()
            .eraseToAnyPublisher()
+       if let combinePublisher = combinePublisher {
+           publisher = combinePublisher(publisher)
+       }
        cancellable = publisher
            .subscribe(on: DispatchQueue.global())
            .sink { entities in
                _ = subscriber.receive(entities)
            }
    }

    func request(_ demand: Subscribers.Demand) {}

    func cancel() {
        cancellable = nil
    }

    func controller(
        _ controller: NSFetchedResultsController<NSFetchRequestResult>,
        didChangeContentWith diff: CollectionDifference<NSManagedObjectID>
    ) {
        guard let models = controller.fetchedObjects as? [Model] else {
            return
        }

        entitiesSubject.send(models.map { $0.toEntity() })
    }
}

After that, combine a Publisher observing an account relationship into a Publisher observing a status on CoreDataStatusCacheStore.watch method.

class CoreDataStatusCacheStore {
    ...
    func watch(by cacheKey: TimelineCacheKey) -> AnyPublisher<[Status], Never> {
        let fetchRequest = CoreDataStatus.fetchRequest()
        fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \CoreDataStatus.createdAt, ascending: false)]
        fetchRequest.predicate = NSPredicate(format: "id in %@", cacheKey.statusCacheKeys.map { $0.statusID })
+       let accountsFetchRequest = CoreDataAccount.fetchRequest()
+       accountsFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \CoreDataAccount.createdAt, ascending: false)]
+       accountsFetchRequest.predicate = NSPredicate(format: "id in %@", cacheKey.statusCacheKeys.map { $0.accountCacheKey.accountID })
+       let accountsPublisher = CoreDataModelPublisher(
+           context: coreDataStore.viewContext,
+           fetchRequest: accountsFetchRequest
+       )
        return CoreDataModelPublisher(
            context: coreDataStore.viewContext,
            fetchRequest: fetchRequest,
+           combinePublisher: { statusesPublisher in
+               statusesPublisher
+                   .combineLatest(accountsPublisher)
+                   .map { statuses, accounts in
+                       statuses.combined(with: accounts)
+                   }
+                   .eraseToAnyPublisher()
+           }
+       ).eraseToAnyPublisher()
    }

    func watch(by cacheKey: StatusCacheKey) -> AnyPublisher<Status, Never> {
        let fetchRequest = CoreDataStatus.fetchRequest()
        fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \CoreDataStatus.createdAt, ascending: false)]
        fetchRequest.fetchLimit = 1
        fetchRequest.predicate = NSPredicate(format: "id = %@", cacheKey.statusID)
+       let accountsFetchRequest = CoreDataAccount.fetchRequest()
+       accountsFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \CoreDataAccount.createdAt, ascending: false)]
+       accountsFetchRequest.fetchLimit = 1
+       accountsFetchRequest.predicate = NSPredicate(format: "id = %@", cacheKey.accountCacheKey.accountID)
+       let accountsPublisher = CoreDataModelPublisher(
+           context: coreDataStore.viewContext,
+           fetchRequest: accountsFetchRequest
+       )
        return CoreDataModelPublisher(
            context: coreDataStore.viewContext,
            fetchRequest: fetchRequest,
+           combinePublisher: { statusesPublisher in
+               statusesPublisher
+                   .combineLatest(accountsPublisher)
+                   .map { statuses, accounts in
+                       statuses.combined(with: accounts)
+                   }
+                   .eraseToAnyPublisher()
+           }
       )
        .compactMap { $0.first }
        .eraseToAnyPublisher()
    }
}

Create a CoreDataAccountCacheStore to cache Account in the same way.

Cache Key Store

Next, create an InMemoryCacheKeyStore class to store identifiers to be assigned to cache objects.

We provide a cacheKey property of type AnyPublisher<CacheKey, Never> so that we can observe cache key changes.

class InMemoryCacheKeyStore<CacheKey: Equatable> {
    private let _cacheKey = CurrentValueSubject<CacheKey?, Never>(nil)
    var cacheKey: AnyPublisher<CacheKey, Never> {
        _cacheKey.compactMap { $0 }.removeDuplicates().eraseToAnyPublisher()
    }
    var currentCacheKey: CacheKey? {
        _cacheKey.value
    }

    func store(_ cacheKey: CacheKey) {
        _cacheKey.send(cacheKey)
    }
}

For example, if you fetch the home timeline response, the CacheKey to store would be an array of a Status ID (and an Account ID pair to observe relationship).

Repository

We will implement the process of throwing a request to the API, retrieving the response, and storing it in the cache in a Repository.

Let's take the home timeline as an example.

Create a HomeTimelineRepository struct and add a process to retrieve the timeline from the API and cache it.

struct HomeTimelineRepository {
    let apiClient: APIClient = .init()
    let statusCacheStore: CoreDataStatusCacheStore = .init()
    let cacheKeyStore: InMemoryCacheKeyStore<TimelineCacheKey> = .init()

    func fetchTimeline() async throws {
        let response = try await apiClient.send(GetHomeTimelineRequest())
        try await statusCacheStore.store(response)
        cacheKeyStore.store(TimelineCacheKey(statusCacheKeys: response.map { status in
            StatusCacheKey(statusID: status.id, accountCacheKey: .init(accountID: status.account.id))
        }))
    }

In addition, implement a method to add/remove posts to/from favorites.

struct HomeTimelineRepository {
    ...
    func favoriteStatus(by id: Status.ID) async throws {
        let response = try await apiClient.send(PostStatusFavoriteRequest(id: id))
        try await statusCacheStore.store(response)
    }

    func unfavoriteStatus(by id: Status.ID) async throws {
        let response = try await apiClient.send(PostStatusUnfavoriteRequest(id: id))
        try await statusCacheStore.store(response)
    }
}

UI

In the UI implementation of the home timeline, call HomeTimelineRepository.watch method at View initialization to start observing cache changes, and call HomeTimelineRepository.fetchTimeline on View appeared to retrieve the timeline from the API and store it in the cache.

Handle the event of a favorite add/delete button tap on each post and call the corresponding HomeTimelineRepository method appropriately.

This allows observed cache changes to be reflected in the UI automatically.

@MainActor
class HomeTimelineViewModel: ObservableObject {
    @Published private(set) var uiState: HomeTimelineUIState = .initial

    private let homeTimelineRepository: HomeTimelineRepository
    private var cancellables = Set<AnyCancellable>()

    init(homeTimelineRepository: HomeTimelineRepository = .init()) {
        self.homeTimelineRepository = homeTimelineRepository
        homeTimelineRepository.watch()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] statuses in
                if statuses.isEmpty {
                    self?.uiState = .noStatuses
                } else {
                    self?.uiState = .hasStatuses(statuses: statuses)
                }
            }
            .store(in: &cancellables)
    }

    func onAppear() async {
        guard case .initial = uiState else { return }

        do {
            uiState = .loading
            try await homeTimelineRepository.fetchInitialTimeline()
        } catch {
            print(error)
        }
    }
    
    func onFavoriteTapped(statusID: Status.ID) async {
        do {
            try await homeTimelineRepository.favoriteStatus(by: statusID)
        } catch {
            print(error)
        }
    }

    func onUnfavoriteTapped(statusID: Status.ID) async {
        do {
            try await homeTimelineRepository.unfavoriteStatus(by: statusID)
        } catch {
            print(error)
        }
    }
}
struct HomeTimelineScreen: View {
    @StateObject private var viewModel: HomeTimelineViewModel

    init(homeTimelineRepository: HomeTimelineRepository = .init()) {
        _viewModel = StateObject(wrappedValue: HomeTimelineViewModel())
    }

    var body: some View {
        ZStack {
            switch viewModel.uiState {
            case .initial:
                ZStack {}
            case .loading:
                ProgressView()
            case .noStatuses:
                Text("No statuses")
            case let .hasStatuses(statuses):
                TimelineContent(
                    statuses: statuses,
                    onFavoriteTapped: { statusID in
                        Task {
                            await viewModel.onFavoriteTapped(statusID: statusID)
                        }
                    },
                    onUnfavoriteTapped: { statusID in
                        Task {
                            await viewModel.onUnfavoriteTapped(statusID: statusID)
                        }
                    }
                )
            }
        }
        .task {
            await viewModel.onAppear()
        }
    }
}

The same applies to other screens, define a corresponding Repository and connect it appropriately to cache that should be observed.

Summary

This is how to implement a mastodon app that adopts a state management architecture with Normalized Cache.

At closing, I would like to thank for the official mastodon app, which has been very helpful.

https://github.com/mastodon/mastodon-ios

You might also like...
Recipes app written in SwiftUI using Single State Container
Recipes app written in SwiftUI using Single State Container

swiftui-recipes-app Recipes app is written in SwiftUI using Single State Container This app implemented as an example of a Single State Container conc

A SwiftUI implementation of React Hooks. Enhances reusability of stateful logic and gives state and lifecycle to function view.

SwiftUI Hooks A SwiftUI implementation of React Hooks. Enhances reusability of stateful logic and gives state and lifecycle to function view. Introduc

GroceryMartApp-iOS-practice - To Practice fundamental SwiftUI feature like navigation, state mamagement, customazing etc
GroceryMartApp-iOS-practice - To Practice fundamental SwiftUI feature like navigation, state mamagement, customazing etc

πŸ₯¬ GroceryMartApp-iOS-practice μ•„λž˜μ˜ λ‚΄μš©μ€ μŠ€μœ—ν•œ SwiftUI μ±…μ˜ μ‹€μ „ μ•± κ΅¬ν˜„ν•˜κΈ° 을 λ°”νƒ•μœΌλ‘œ μ •λ¦¬ν•œ λ‚΄μš©μž…λ‹ˆλ‹€

The Discord API implementation behind Swiftcord, implemented completely from scratch in Swift

DiscordKit The Discord API implementation that powers Swiftcord This implementation has fully functional REST and Gateway support, but is mainly geare

A simple Student Management on an iOS App
A simple Student Management on an iOS App

Student Management XCode Swift App A simple Student Management on an Xcode Swift App Table of Contents About the projects Technologies Features Setup

Event management iOS app for organizers using Open Event Platform
Event management iOS app for organizers using Open Event Platform

Open Event Organizer iOS App Event management app for organizers using Open Event Platform Roadmap Make the app functionality and UI/UX similar to the

Aquarium Life is an iPhone app for management of Home Aquariums
Aquarium Life is an iPhone app for management of Home Aquariums

Aquarium life Aquarium Life is an iPhone app for management of Home Aquariums. The app was launched on Apple App Store in 2021 but removed after my me

Trello-style Task Management App
Trello-style Task Management App

Krello Trello-style Task Management App κΈ°ν•œ: 2022.05.04 ~ 05.20 (13일) ν”„λ‘œμ νŠΈμ— λŒ€ν•œ μžμ„Έν•œ λ‚΄μš©μ€ πŸ‘‰ Notion μ—μ„œ 확인할 수 μžˆμŠ΅λ‹ˆλ‹€ μ•± μ†Œκ°œ Trello 의 κΈ°λŠ₯을 따라 할일을 κ΄€λ¦¬ν•˜λŠ” iOS Applic

Porting the example app from our Advanced iOS App Architecture book from UIKit to SwiftUI.

SwiftUI example app: Koober We're porting the example app from our Advanced iOS App Architecture book from UIKit to SwiftUI and we are sharing the cod

Owner
null
Implemented MVVM-C (Coordinator) architecture pattern for the project. Which is satisfying SOLID principles altogether. Protocol oriented development has been followed.

BreakingBad BreakingBad API doc Implemented MVVM-C (Coordinator) architecture pattern for the project. Which is satisfying SOLID principples altogethe

Dhruvik Rao 2 Mar 10, 2022
Learn how to structure your iOS App with declarative state changes using Point-Free's The Composable Architecture (TCA) library.

Learn how to structure your iOS App with declarative state changes using Point-Free's The Composable Architecture (TCA) library.

Tiago Henriques 0 Nov 6, 2021
Best architecture for SwiftUI + CombineBest architecture for SwiftUI + Combine

Best architecture for SwiftUI + Combine The content of the presentation: First of the proposed architectures - MVP + C Second of the proposed architec

Kyrylo Triskalo 3 Sep 1, 2022
SwiftUI sample app using Clean Architecture. Examples of working with CoreData persistence, networking, dependency injection, unit testing, and more.

Articles related to this project Clean Architecture for SwiftUI Programmatic navigation in SwiftUI project Separation of Concerns in Software Design C

Alexey Naumov 3.8k Sep 23, 2022
Mvi Architecture for SwiftUI Apps. MVI is a unidirectional data flow architecture.

Mvi-SwiftUI If you like to read this on Medium , you can find it here MVI Architecture for SwiftUI Apps MVI Architecture Model-View-Intent (MVI) is a

null 10 Aug 29, 2022
Orbit-swiftui - Orbit design system implemented in SwiftUI for iOS

Orbit is a SwiftUI component library which provides developers the easiest possi

Kiwi.com 27 Sep 21, 2022
Beers is a simple experimental app implemented using the new amazing SwiftUI.

Beers is a simple experimental app implemented using the new amazing SwiftUI. The app shows a list of beers fetched from Punk API

Chris 26 Aug 23, 2022
WeChat-like Moments App implemented using Swift 5.5 and SwiftUI

Moments SwiftUI This is a re-implementation of Moments App using Swift 5.5 and SwiftUI. Features: Aysnc/Await Actor AysncImage MVVM BFF Screenshot Als

Jake Lin 43 Jul 15, 2022
Diagrams of Combine Publishers implemented with SwiftUI

CombineMarbles for iOS Diagrams of Combine Publishers implemented with SwiftUI Combine is a new library for composing asynchronous events over time (R

Antoine Garcia 53 Dec 11, 2021
Docker management app for iOS written in SwiftUI.

Harbour Docker management app for iOS written in SwiftUI. Screenshots Features 100% native and made in SwiftUI Light and Dark Mode Control and inspect

null 213 Sep 20, 2022