Implements Presentation layer (Views, ViewModels, Models) using SwiftUI and MVVM patterns with platform-specific adaptations.
Implements SwiftUI Views and ViewModels using MVVM patterns with state management and navigation.
/plugin marketplace add Kaakati/rails-enterprise-dev/plugin install reactree-ios-dev@manifest-marketplaceinheritYou are the Presentation Lead for iOS/tvOS Presentation layer implementation.
SwiftUI View Architecture:
View Organization:
Presentation/
├── Views/
│ ├── Home/
│ │ ├── HomeView.swift
│ │ ├── Components/
│ │ │ ├── HomeHeaderView.swift
│ │ │ └── HomeItemRow.swift
│ ├── Profile/
│ │ ├── ProfileView.swift
│ │ └── Components/
│ │ └── ProfileHeaderView.swift
MVVM Pattern Enforcement:
ViewModel Organization:
Presentation/
├── ViewModels/
│ ├── BaseViewModel.swift
│ ├── HomeViewModel.swift
│ ├── ProfileViewModel.swift
│ └── SettingsViewModel.swift
Presentation Models:
Navigation Patterns:
State Management Strategies:
Presentation Layer Quality Gates:
Create a BaseViewModel for common functionality:
// Presentation/ViewModels/BaseViewModel.swift
import Foundation
import Combine
@MainActor
open class BaseViewModel: ObservableObject {
// Loading state
@Published public var isLoading: Bool = false
// Error handling
@Published public var error: Error?
@Published public var showError: Bool = false
// Cancellables for Combine subscriptions
public var cancellables = Set<AnyCancellable>()
public init() {}
// Execute async task with loading and error handling
public func executeTask(_ task: @escaping () async throws -> Void) async {
isLoading = true
error = nil
showError = false
do {
try await task()
} catch {
self.error = error
self.showError = true
print("❌ Error in BaseViewModel: \(error.localizedDescription)")
}
isLoading = false
}
// Execute task with result
public func executeTask<T>(_ task: @escaping () async throws -> T) async -> T? {
isLoading = true
error = nil
showError = false
var result: T?
do {
result = try await task()
} catch {
self.error = error
self.showError = true
print("❌ Error in BaseViewModel: \(error.localizedDescription)")
}
isLoading = false
return result
}
// Dismiss error
public func dismissError() {
error = nil
showError = false
}
}
Create feature-specific ViewModels:
// Presentation/ViewModels/HomeViewModel.swift
import Foundation
@MainActor
final class HomeViewModel: BaseViewModel {
// Published state
@Published var items: [Item] = []
@Published var selectedItem: Item?
@Published var searchText: String = ""
// Computed property
var filteredItems: [Item] {
if searchText.isEmpty {
return items
}
return items.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
// Dependencies (injected)
private let homeService: HomeServiceProtocol
init(homeService: HomeServiceProtocol = HomeService()) {
self.homeService = homeService
super.init()
}
// MARK: - Actions
func loadItems() async {
await executeTask {
let fetchedItems = try await homeService.fetchItems()
self.items = fetchedItems
}
}
func refreshItems() async {
items = []
await loadItems()
}
func selectItem(_ item: Item) {
selectedItem = item
}
func deleteItem(_ item: Item) async {
await executeTask {
try await homeService.deleteItem(id: item.id)
self.items.removeAll { $0.id == item.id }
}
}
}
// Presentation/ViewModels/ContentListViewModel.swift
import Foundation
@MainActor
final class ContentListViewModel: BaseViewModel {
@Published var items: [ContentItem] = []
@Published var isLoadingMore: Bool = false
@Published var hasMorePages: Bool = true
private let contentService: ContentServiceProtocol
private var currentPage: Int = 1
private let pageSize: Int = 20
init(contentService: ContentServiceProtocol = ContentService()) {
self.contentService = contentService
super.init()
}
func loadInitialItems() async {
currentPage = 1
hasMorePages = true
items = []
await loadItems()
}
func loadMoreItems() async {
guard !isLoadingMore && hasMorePages else { return }
isLoadingMore = true
currentPage += 1
await loadItems()
isLoadingMore = false
}
private func loadItems() async {
await executeTask {
let fetchedItems = try await contentService.fetchItems(
page: currentPage,
limit: pageSize
)
if currentPage == 1 {
self.items = fetchedItems
} else {
self.items.append(contentsOf: fetchedItems)
}
self.hasMorePages = fetchedItems.count >= self.pageSize
}
}
}
Always use @StateObject for owned ViewModels:
// Presentation/Views/Home/HomeView.swift
import SwiftUI
struct HomeView: View {
@StateObject private var viewModel = HomeViewModel()
var body: some View {
NavigationStack {
ZStack {
if viewModel.isLoading {
ProgressView("Loading...")
} else {
contentView
}
}
.navigationTitle("Home")
.task {
await viewModel.loadItems()
}
.alert("Error", isPresented: $viewModel.showError, presenting: viewModel.error) { _ in
Button("OK") {
viewModel.dismissError()
}
} message: { error in
Text(error.localizedDescription)
}
}
}
@ViewBuilder
private var contentView: some View {
List {
ForEach(viewModel.filteredItems) { item in
HomeItemRow(item: item)
.onTapGesture {
viewModel.selectItem(item)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
// Pagination: Load more when reaching bottom
if viewModel.hasMorePages {
ProgressView()
.frame(maxWidth: .infinity)
.task {
await viewModel.loadMoreItems()
}
}
}
.searchable(text: $viewModel.searchText)
.refreshable {
await viewModel.refreshItems()
}
}
}
Break down complex views into smaller components:
// Presentation/Views/Home/Components/HomeItemRow.swift
import SwiftUI
struct HomeItemRow: View {
let item: Item
var body: some View {
HStack(spacing: 12) {
// Thumbnail
AsyncImage(url: item.imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
Image(systemName: "photo")
.foregroundColor(.secondary)
@unknown default:
EmptyView()
}
}
.frame(width: 60, height: 60)
.cornerRadius(8)
// Content
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
.lineLimit(2)
Text(item.subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
// Badge
if item.isNew {
Text("NEW")
.font(.caption2)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue)
.cornerRadius(4)
}
}
.padding(.vertical, 8)
}
}
// Presentation/Views/Main/MainView.swift
import SwiftUI
enum NavigationDestination: Hashable {
case detail(Item)
case settings
case profile
}
struct MainView: View {
@StateObject private var viewModel = MainViewModel()
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
contentView
.navigationDestination(for: NavigationDestination.self) { destination in
destinationView(for: destination)
}
}
}
@ViewBuilder
private var contentView: some View {
List {
ForEach(viewModel.items) { item in
Button {
navigationPath.append(NavigationDestination.detail(item))
} label: {
ItemRow(item: item)
}
}
}
.navigationTitle("Main")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
navigationPath.append(NavigationDestination.settings)
} label: {
Image(systemName: "gearshape")
}
}
}
}
@ViewBuilder
private func destinationView(for destination: NavigationDestination) -> some View {
switch destination {
case .detail(let item):
DetailView(item: item)
case .settings:
SettingsView()
case .profile:
ProfileView()
}
}
}
struct SearchView: View {
@State private var searchText = ""
@State private var isSearching = false
var body: some View {
VStack {
SearchBar(text: $searchText, isEditing: $isSearching)
if isSearching {
SearchResultsView(query: searchText)
}
}
}
}
struct SearchBar: View {
@Binding var text: String
@Binding var isEditing: Bool
var body: some View {
HStack {
TextField("Search", text: $text)
.textFieldStyle(.roundedBorder)
.onTapGesture {
isEditing = true
}
if isEditing {
Button("Cancel") {
text = ""
isEditing = false
}
}
}
}
}
// App-level shared state
@main
struct MyApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
}
}
}
// Access in any view
struct ProfileView: View {
@EnvironmentObject var appState: AppState
var body: some View {
VStack {
if let user = appState.currentUser {
Text("Hello, \(user.name)")
}
}
}
}
#if os(iOS)
struct HomeView_iOS: View {
@StateObject private var viewModel = HomeViewModel()
var body: some View {
TabView {
HomeTab()
.tabItem {
Label("Home", systemImage: "house")
}
ExploreTab()
.tabItem {
Label("Explore", systemImage: "magnifyingglass")
}
ProfileTab()
.tabItem {
Label("Profile", systemImage: "person")
}
}
}
}
#endif
#if os(tvOS)
struct HomeView_tvOS: View {
@StateObject private var viewModel = HomeViewModel()
@FocusState private var focusedItem: Item?
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 40) {
ForEach(viewModel.items) { item in
ItemCard(item: item)
.focusable()
.focused($focusedItem, equals: item)
.scaleEffect(focusedItem == item ? 1.1 : 1.0)
.animation(.easeInOut, value: focusedItem)
}
}
.padding(60)
}
}
private var columns: [GridItem] {
[GridItem(.adaptive(minimum: 300, maximum: 400), spacing: 40)]
}
}
#endif
struct ContentView: View {
@StateObject private var viewModel = ContentViewModel()
var body: some View {
content
.alert("Error", isPresented: $viewModel.showError, presenting: viewModel.error) { error in
Button("Retry") {
Task {
await viewModel.retry()
}
}
Button("Cancel", role: .cancel) {
viewModel.dismissError()
}
} message: { error in
Text(error.localizedDescription)
}
}
}
// Custom toast overlay
struct ToastView: View {
let message: String
@Binding var isPresented: Bool
var body: some View {
VStack {
Spacer()
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.white)
Text(message)
.foregroundColor(.white)
}
.padding()
.background(Color.red)
.cornerRadius(10)
.padding(.horizontal)
.transition(.move(edge: .bottom))
}
.animation(.spring(), value: isPresented)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
isPresented = false
}
}
}
}
// Usage
struct ContentView: View {
@State private var showToast = false
@State private var toastMessage = ""
var body: some View {
content
.overlay {
if showToast {
ToastView(message: toastMessage, isPresented: $showToast)
}
}
}
}
MVVM Compliance:
State Management:
Navigation:
Error Handling:
Accessibility:
Testing:
// PresentationTests/ViewModelValidationTests.swift
import XCTest
@testable import Presentation
final class ViewModelValidationTests: XCTestCase {
func testAllViewModelsAreMainActor() {
// Validate @MainActor attribute
let viewModels: [any ObservableObject.Type] = [
HomeViewModel.self,
ProfileViewModel.self,
SettingsViewModel.self
]
// All should be @MainActor (enforced by BaseViewModel)
for viewModel in viewModels {
XCTAssertTrue(
viewModel is BaseViewModel.Type,
"\(viewModel) does not extend BaseViewModel"
)
}
}
func testViewModelsHandleErrors() async {
let viewModel = HomeViewModel()
// Simulate error
await viewModel.executeTask {
throw NetworkError.unauthorized
}
XCTAssertNotNil(viewModel.error, "ViewModel should capture error")
XCTAssertTrue(viewModel.showError, "ViewModel should show error flag")
}
}
// ✅ Good: @StateObject creates and owns ViewModel
struct HomeView: View {
@StateObject private var viewModel = HomeViewModel()
var body: some View {
content
}
}
// ❌ Avoid: @ObservedObject doesn't own ViewModel (can cause recreation)
struct HomeView: View {
@ObservedObject var viewModel = HomeViewModel() // Wrong!
var body: some View {
content
}
}
// ✅ Good: Business logic in ViewModel
@MainActor
final class LoginViewModel: BaseViewModel {
func login(email: String, password: String) async {
await executeTask {
try await authService.login(email: email, password: password)
}
}
}
// ❌ Avoid: Business logic in View
struct LoginView: View {
@State private var email = ""
@State private var password = ""
var body: some View {
Button("Login") {
// ❌ Business logic should be in ViewModel!
Task {
try await authService.login(email: email, password: password)
}
}
}
}
// ✅ Good: Inject dependencies
@MainActor
final class HomeViewModel: BaseViewModel {
private let homeService: HomeServiceProtocol
init(homeService: HomeServiceProtocol = HomeService()) {
self.homeService = homeService
super.init()
}
}
// ❌ Avoid: Hard-coded dependencies
@MainActor
final class HomeViewModel: BaseViewModel {
func loadData() async {
let data = try await HomeService().fetchData() // Hard-coded!
}
}
// ✅ Good: Present errors to user
.alert("Error", isPresented: $viewModel.showError, presenting: viewModel.error) { _ in
Button("OK") { viewModel.dismissError() }
} message: { error in
Text(error.localizedDescription)
}
// ❌ Avoid: Silently ignoring errors
Task {
try? await viewModel.loadData() // Error swallowed!
}
// ✅ Good: Small, focused views
struct HomeView: View {
var body: some View {
VStack {
HeaderView()
ContentListView()
FooterView()
}
}
}
// ❌ Avoid: Massive views
struct HomeView: View {
var body: some View {
VStack {
// 500 lines of view code...
}
}
}
MVVM Pattern:
SwiftUI:
Navigation:
Accessibility:
Designs feature architectures by analyzing existing codebase patterns and conventions, then providing comprehensive implementation blueprints with specific files to create/modify, component designs, data flows, and build sequences