Skip to content

Instantly share code, notes, and snippets.

@paulweichhart
Last active November 24, 2022 21:40
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save paulweichhart/21f4aad6f5741c6013450acac4f4ab0e to your computer and use it in GitHub Desktop.
Save paulweichhart/21f4aad6f5741c6013450acac4f4ab0e to your computer and use it in GitHub Desktop.
MVVM+SwiftUI
//
// MVVM.swift
// MVVM+SwiftUI+Combine
//
// Created by Paul Weichhart on 07.09.20.
//
import Combine
import Foundation
import SwiftUI
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
let networkLayer = NetworkLayer()
let viewModel = ViewModel(networkLayer: networkLayer)
PortfolioView(viewModel: viewModel)
}
}
}
enum RequestError: Error {
case malformedURL
case networkError
}
struct Symbol: Codable {
let id: String
let title: String
var tags: String {
return id.localizedLowercase + " " + title.localizedLowercase
}
enum CodingKeys: String, CodingKey {
case id = "symbol"
case title
}
}
final class NetworkLayer {
private static let symbolsURL = URL(string: "https://www.paulweichhart.com/symbols.json")
private let decoder = JSONDecoder()
var symbols: AnyPublisher<[Symbol], RequestError> {
let publisher: AnyPublisher<[Symbol], RequestError> = fetch(url: Self.symbolsURL)
return publisher
.map { symbols in
return symbols.sorted(by: { $0.id < $1.id })
}
.eraseToAnyPublisher()
}
// The generic Type `NetworkModel` allows reusing the core functionality even for Arrays
private func fetch<NetworkModel: Codable>(url: URL?) -> AnyPublisher<NetworkModel, RequestError> {
guard let url = url else {
// Avoid using force unwrap with returning a `RequestError`
return Result<NetworkModel, RequestError>
.Publisher(.failure(.malformedURL))
.eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.retry(3)
.map { $0.data }
.decode(type: NetworkModel.self, decoder: self.decoder)
.receive(on: RunLoop.main)
.mapError { _ in return .networkError }
.eraseToAnyPublisher()
}
}
final class ViewModel: ObservableObject {
// Using `Result` allows forwarding the Error to the View & rendering an according Error message
@Published private(set) var symbols: Result<[Symbol], RequestError>?
@Published var searchText = ""
private let networkLayer: NetworkLayer
private var cancellables = Set<AnyCancellable>()
init(networkLayer: NetworkLayer) {
self.networkLayer = networkLayer
subscribe()
}
private func subscribe() {
// SearchText has the Failure Type Never and needs to match the symbols Error Type
Publishers.CombineLatest(networkLayer.symbols, $searchText.setFailureType(to: RequestError.self))
.map { symbols, searchText in
let text = searchText.localizedLowercase
let result = text.count < 3 ? symbols : symbols.filter { $0.tags.contains(text) }
return .success(result)
}
.sink(receiveCompletion: { [weak self] completion in
switch completion {
case let .failure(error):
self?.symbols = .failure(error)
case .finished:
break
}
}, receiveValue: { [weak self] value in
self?.symbols = value
})
.store(in: &cancellables)
}
}
struct PortfolioView: View {
@StateObject private var viewModel: ViewModel
init(viewModel: ViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
NavigationView {
VStack {
HeaderView(searchText: $viewModel.searchText)
switch viewModel.symbols {
case let .success(symbols):
PortfolioList(symbols: symbols)
case let .failure(error):
Spacer()
ErrorView(error: error)
Spacer()
case .none:
Spacer()
LoadingView()
Spacer()
}
}
.padding(EdgeInsets(top: 0, leading: 8.0, bottom: 0, trailing: 8.0))
.navigationBarHidden(true)
}
}
}
struct HeaderView: View {
@Binding var searchText: String
var body: some View {
VStack(alignment: .leading) {
Text("Portfolio")
.font(.largeTitle)
.fontWeight(.bold)
.padding(EdgeInsets(top: 0, leading: 8, bottom: -8, trailing: 8))
TextField("Search for Symbol", text: $searchText)
.padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
.background(Color.gray.opacity(0.2))
.cornerRadius(8)
.clipped()
}
}
}
struct LoadingView: View {
var body: some View {
VStack {
ProgressView()
Text("Loading")
.padding(EdgeInsets(top: 0, leading: 8.0, bottom: 0, trailing: 8.0))
}
}
}
struct ErrorView: View {
let error: RequestError
var body: some View {
VStack {
switch error {
case .networkError:
Text("Couldn't load Symbols — please check your Internet Connection")
default:
Text("Couldn't load Symbols — please retry")
}
}.padding(EdgeInsets(top: 0, leading: 8.0, bottom: 0, trailing: 8.0))
}
}
struct PortfolioList: View {
let symbols: [Symbol]
var body: some View {
List {
ForEach(symbols, id: \.id) { symbol in
VStack(alignment: .leading) {
Text(symbol.id.localizedUppercase)
.bold()
Text(symbol.title).foregroundColor(Color.gray.opacity(0.7))
}.padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
}
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
.listStyle(PlainListStyle())
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment