It seems that now (iOS 16) we can apply button styles to NavigationLink.
NavigationLink("Show Terms") {
TermsView()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
I don’t know if is iOS 16 only or not but this is huge improvement.
Thanks
Post
Replies
Boosts
Views
Activity
Some things that works on iOS 16:
Refreshable modifier work on ScrollView
We can change TextEditor background hiding the default background “.scrollContentBackground(.hidden)”
Change TextField placeholder font and color “…, prompt: Text(…).font(…).foregroundColor(…)” - as of iOS 15
Hi, posted something about that in older posts, for big projects you can have specific models (modules). You never end up with a big objects. I do large projects very easy to do and productive for the team. Scale very well and with clear (true) “separation of the concerns“.
The goal of architecture is to make your life (or your team’s life) easier. If this is not happening, then your architecture has failed.
…and sorry to repeat… Model != Entities
Model objects represent special knowledge and expertise. They hold an application’s data and define the logic that manipulates that data. The model is a class diagram containing the logic, data, algorithms, … of the software.
For example, you can think Apple Platform as a big project:
// Can be internal (folders inside project) or external (sub-modules, packages) if makes sense
// Independent and focused teamwork
// Easy to build, test and maintenance
Contacts (model)
ContactsUI
EventKit (the model)
EventKitUI
Message (the model)
MessageUI
CoreLocation (the model)
CoreLocationUI
Shared
SharedUI
[module]
[module]UI
Or a TV app:
User (model)
UserUI
LiveTV (the model)
LiveTVUI
VODMovies (the model)
VODMoviesUI
VODSeries (the model)
VODSeriesUI
Shared
SharedUI
Everyday I see many, small to big, Clean / VIP / VIPER projects with a massive Entities (…Interactors, Presenters, …) folders without a clear separation of the things where all the team members work on that (all things), creating problematic situations and complexity hell. Recently we watch the bad experience (NSSpain video) from SoundCloud team and how “Uncle Bod” things broke the promise.
Difference between .NET View and SwiftUI View. Two different technologies means two different architecture approaches.
Differences between MVVM and SwiftUI.
Software Engineering (design / modeling / specs) is one of the most important work of our jobs. I’m trying to find a simple diagram for SwiftUI to:
Identify Objects (structs / classes)
Identify Tasks (methods)
Identify Dependencies
A modified version of old (but gold) DFD (Data-flow diagram) fits very well on SwiftUI data-driven architecture. Also helps to understand how SwiftUI works. I’m calling it “Data-driven diagram”. The “data element” in diagram is independent of Data Access Patterns (Active Record, Repository, POJOs, …).
Shopping Example
Books App Example
Just watched the session “13 - Lessons learnt rewriting SoundCloud in SwiftUI - Matias Villaverde & Rens Breur” from NSSpain. They rewrite the UIKit app to SwiftUI using “Clean Architecture” + “View Models”… and guess… they failed!
Again, learn and understand SwiftUI paradigm before start a project. Don’t fight the system, Keep It Simple!
As I said before we can do all the work in the stores (SOT) and keeping Channel and Movie plain (only properties). Personally I use Active Record to separating the data access from Source of Truth. Also I have cases that I use the some data object factory method in multiple store. And some different but related projects that use the some web services (data access) but not the Source of Truths (stores). Active Record works great for library / frameworks and integration tests. Also in a team we can split the responsibilities with team members… Data Access, Source of Truth, View, Shared, …
Channels tasks
struct Channel {
// Data
var isFavorite: Bool {
didSet {
Task {
try? await MyTVWS.shared.request(resource: "channels/\(id)",
verb: .patch,
payload: ...)
}
}
}
}
struct Channel {
// Data
// Tasks
func addToFavorites() async throws {
try await MyTVWS.shared.request(resource: "channels/\(id)",
verb: .patch,
payload: ...)
}
func removeFromFavorites() async throws {
try await MyTVWS.shared.request(resource: "channels/\(id)",
verb: .patch,
payload: ...)
}
}
Movies management
Example 1 - Use the NotificationCenter to broadcast a Movie change, very common in IOS platform for model change notifications, include the changed movie object in "sender" or "info", add observer on the MovieStore and update / reload / invalidate the store.
extension Notification.Name {
static let movieDidChange = Notification.Name("movieDidChangeNotification")
}
Example 2 - MovieStore as a Single Source of Truth. Be careful with large data sets (memory). In this example you can see why I use Active Record, I can use Movie.movies factory method in many situations.
class MovieStore: ObservableObject {
@Published var featured: [Movie] = []
@Published var currentlyViewing: [Movie] = []
@Published var favorites: [Movie] = []
@Published var byGenres: [Movie.Genre: Movie] = []
@Published var allMovies: [Movie] = [] // current grid
// load, phase, ...
}
struct MovieList: View {
@StateObject private var store = MovieStore()
// mode, ...
var body: some View {
VStack {
Picker("", selection: $mode) { ... }
.pickerStyle(.segmented)
ScrollView {
LazyVStack {
switch mode {
case .home:
MovieRow(..., movies: store.featured)
MovieRow(..., movies: store.currentlyViewing)
MovieRow(..., movies: store.favorites)
case .genres:
ForEach(...) { movies in
MovieRow(..., movies: movies)
}
}
}
}
.environmentObject(store)
}
}
}
Example 3 - MovieStore as movie sync manager for Core Data (in memory or in disk) / relational database. Requires more work and a local data model.
class MovieStore: ObservableObject { ... } // movies sync and management
struct MovieRow: View {
@FetchRequest(...)
private var movies: FetchedResults<Movie>
// ...
}
There’s an unlimited solutions (and system frameworks) for our problems. We just need to think in our model and how designing the model (data-driven SwiftUI nature) that fits app use cases.
Note: For a single feature / data source apps (e.g. Mail, Notes, Reminders, …) we can use a global / single state ObservableObject. But in many apps we made we have many sections / features / data sources and we need more ObservableObjects / Stores (SOT). Also from my experience ObservableObject to ObservableObject communication / observation is not good and can become confuse, I avoid flow like this: View - ObservableObject - ObservableObject - Data Object
Checkout process example
SwiftUI approach
// Model layer
class Checkout: ObservableObject { ... }
// View layer
struct CheckoutView: View {
@StateObject private var checkout = Checkout()
var body: some View {
NavigationStack {
CheckoutProductInfoForm()
// -> CheckoutOffersView()
// -> CheckoutBuyerForm()
// -> CheckoutDeliveryInfoForm()
// -> CheckoutSummaryView()
}
.environmentObject(checkout)
}
}
Advantages:
Clean, simple and data-driven development
Core for declarative UI platforms
Checkout model object is independent from a specific View and platform
Works great for multiplatform (inside Apple ecosystem)
Disadvantages:
Other platform devs don’t (yet) understand it
MVVM approach
// Need a model or helper object to share / joint data between the VMs
// ViewModel layer
class CheckoutProductInfoViewModel: ObservableObject { ... }
class CheckoutOffersViewModel: ObservableObject { ... }
class CheckoutBuyerViewModel: ObservableObject { ... }
class CheckoutDeliveryInfoViewModel: ObservableObject { ... }
class CheckoutSummaryViewModel: ObservableObject { ... }
// View layer
struct CheckoutView: View {
var body: some View {
NavigationStack {
CheckoutProductInfoView() // <- CheckoutProductInfoViewModel
// -> CheckoutOffersView() <- CheckoutOffersViewModel
// -> CheckoutBuyerView() <- CheckoutBuyerViewModel
// -> CheckoutDeliveryInfoView() <- CheckoutDeliveryInfoViewModel
// -> CheckoutSummaryView() <- CheckoutSummaryViewModel
}
}
}
Advantages:
Sometimes we feel that using VMs can help to avoid massive views (but not really necessary -> SwiftUI component nature)
Disadvantages:
A middle layer, unnecessary, more objects / files, more code, more complexity
Not easy to handle some use cases, becomes problematic in some situations
Not easy to share a state in view hierarchy
ViewModel-View dependence becomes bad for multiplatform (iOS UI != iPad UI != TV UI …)
Old approach, not suitable for declarative platforms
Can fight the SwiftUI platform
About the service object
Active Record pattern
StoreKit 2 approach
// Handles requests, environments, tokens, …
// General access to an specific web service using an URLSession instance
class MyTVWS {
static let shared = MyTVWS()
func request(…) async throws -> T { … }
}
struct Channel: Codable {
// Data (properties)
// Factory Methods
static var channels: [Self] {
get async throws {
try await MyTVWS.shared.request(resource: "channels",
verb: .get)
}
}
}
struct Movie: Codable {
// Data (properties)
// Factory Methods
static func movies(pageNumber: Int = 1,
pageSize: Int = 30) async throws -> [Self] {
try await MyTVWS.shared.request(resource: "movies",
verb: .get,
queryString: ...)
}
}
Advantages:
Better code and object organization
Direct object task access
Works great for modular (multi model) architectures
Easy for team member responsibilities
Perfect for scalability and maintenance
Clean and easy to use
True OOP and Separation of Concerns approach
Disadvantages:
SOLID and anti-OOP principles / patterns devs don’t like it
Massive service object strategy, POJOs
WeatherKit approach, from a team (Dark Sky) that Apple acquired
// Handles requests, environments, tokens, …
// Specific access to an specific web service using an URLSession instance
class MyTVWS {
static let shared = MyTVWS()
func getChannels() async throws -> [Channel] {
try await MyTVWS.shared.request(resource: "channels",
verb: .get)
}
func getMovies(pageNumber: Int = 1,
pageSize: Int = 30) async throws -> [Movie] {
try await MyTVWS.shared.request(resource: "movies",
verb: .get,
queryString: ...)
}
}
struct Channel: Codable {
// Data (properties)
}
struct Movie: Codable {
// Data (properties)
}
Advantages:
Simple data objects
Disadvantages:
Massive single service object (many responsibilities)
Code fragmentation (e.g. Channel related code and functionality present in different files / objects)
Scalability and maintenance problems (e.g. many devs working / changing on single object with many responsibilities)
Movies
Typically a big & unlimited data set
For cache use HTTP / URLSession caching system (defined by the server)
For offline use the stores (no needed in many cases)
Model layer
struct Movie: Identifiable, Hashable, Codable {
let id: String
// Data
enum Section: String, Codable {
case all
case featured
case favorites
case currentlyViewing
// ...
}
enum Genre: String, Identifiable, Codable, CaseIterable {
case action
case comedy
case drama
case terror
case animation
case science
case sports
case western
// ...
var id: Self { self }
}
// Factory Method
static func movies(pageNumber: Int = 1,
pageSize: Int = 30,
search: String? = nil,
section: Movie.Section = .all,
genres: [Movie.Genre] = [],
sort: String? = nil) async throws -> [Self] {
try await MyTVWS.shared.request(resource: "movies",
verb: .get,
queryString: ...)
}
// --- or ---
// (recommended)
struct FetchOptions {
var pageNumber: Int = 1
var pageSize: Int = 30
var search: String? = nil
var section: Section = .all
var genres: [Genre] = []
var sort: String? = nil
}
static func movies(_ options: FetchOptions) async throws -> [Self] {
try await MyTVWS.shared.request(resource: "movies",
verb: .get,
queryString: ...)
}
}
class MovieStore: ObservableObject {
@Published var movies: [Movie] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
var options: Movie.FetchOptions
init(_ options: Movie.FetchOptions) {
self.options = options
}
// Add convenience initializings if wanted
init(_ section: Movie.Section,
genre: Movie.Genre? = nil
limit: Int = 15) {
self.options = ...
}
func load() async {
isLoading = true
do {
movies = try await Movie.movies(options)
} catch {
loadError = error
}
isLoading = false
}
func loadNextPage() async { ... } // Infinite scrolling
}
View layer
struct MovieList: View {
enum Mode {
case home
case genres
}
@State private var mode: Mode = .home
var body: some View {
VStack {
Picker("", selection: $mode) { ... }
.pickerStyle(.segmented)
ScrollView {
LazyVStack {
switch mode {
case .home:
MovieRow(store: MovieStore(.featured))
MovieRow(store: MovieStore(.currentlyViewing))
MovieRow(store: MovieStore(.favorites))
case .genres:
ForEach(Movie.Genre.allCases) { genre in
MovieRow(store: MovieStore(.all, genre: genre))
}
}
}
}
}
}
}
// Each row can be limited to n items and have a "View all" button to push MovieGrid with all items
struct MovieRow: View {
@StateObject var store: MovieStore
var body: some View { ... }
}
struct MovieGrid: View {
@StateObject var store: MovieStore
var body: some View { ... }
}
struct MovieCard: View {
var movie: Movie
var body: some View { ... }
}
Channels
There’s many ways for it, depending on our needs. Just some ideas.
Typically a small & limited data set
For cache use HTTP / URLSession caching system (defined by the server)
For offline use the stores (no needed in many cases)
Model layer - Example 1
struct Channel: Identifiable, Hashable, Codable {
let id: String
// Data
var isFavorite: Bool
// Factory Methods
static var channels: [Self] {
get async throws {
try await MyTVWS.shared.request(resource: "channels",
verb: .get)
}
}
}
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
var favorites: [Channel] { channels.filter { $0.isFavorite } }
@Published var isLoading: Bool = false
var loadError: Error? = nil
func load() async {
isLoading = true
do {
channels = try await Channel.channels
} catch {
loadError = error
}
isLoading = false
}
}
Model layer - Example 2
Check if channel is favorite on the “favorites” store.
struct Channel: Identifiable, Hashable, Codable {
let id: String
// Data
func addToFavorites() async throws { ... }
func removeFromFavorites() async throws { ... }
// Factory Methods
static var channels: [Self] {
get async throws {
try await MyTVWS.shared.request(resource: "channels",
verb: .get)
}
}
static var favoriteChannels: [Self] {
get async throws {
try await MyTVWS.shared.request(resource: "favoriteChannels",
verb: .get)
}
}
}
// 2.1
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
@Published var favoriteChannels: [Channel] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
func load() async {
isLoading = true
do {
channels = try await Channel.channels
favoriteChannels = try await Channel.favoriteChannels
} catch {
loadError = error
}
isLoading = false
}
}
// 2.2
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
enum Source {
case all
case favorites
}
private let source: Source
init(_ source: Source) {
self.source = source
}
func load() async {
isLoading = true
do {
switch source {
case .all:
channels = try await Channel.channels
case.favorites:
channels = try await Channel.favoriteChannels
}
} catch {
loadError = error
}
isLoading = false
}
}
// 2.3
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
open func load() async { }
}
class AllChannelStore: ChannelStore {
func load() async {
isLoading = true
do {
channels = try await Channel.channels
} catch {
loadError = error
}
isLoading = false
}
}
class FavoriteChannelStore: ChannelStore {
func load() async {
isLoading = true
do {
channels = try await Channel.favoriteChannels
} catch {
loadError = error
}
isLoading = false
}
}
// 2.4
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
open func loadChannels() async throws { }
func load() async {
isLoading = true
do {
try await loadChannels()
} catch {
loadError = error
}
isLoading = false
}
}
class AllChannelStore: ChannelStore {
func loadChannels() async throws {
channels = try await Channel.channels
}
}
class FavoriteChannelStore: ChannelStore {
func loadChannels() async throws {
channels = try await Channel.favoriteChannels
}
}
View layer - Based on Example 1
struct ChannelList: View {
@EnvironmentObject private var channelStore: ChannelStore
enum Mode {
case all
case favorites
}
@State private var mode: Mode = .all
var body: some View {
VStack {
Picker("", selection: $mode) { ... }
.pickerStyle(.segmented)
ScrollView {
LazyVStack {
switch mode {
case .all:
ForEach(channelStore.channels) { channel in
ChannelCard(channel: channel)
}
case .favorites:
ForEach(channelStore.favoriteChannels) { channel in
ChannelCard(channel: channel)
}
}
}
}
}
}
}
struct ChannelCard: View {
var channel: Channel
var body: some View { ... }
}
struct ProgramList: View {
@EnvironmentObject private var channelStore: ChannelStore
var body: some View { ... }
}
struct LivePlayerView: View {
@EnvironmentObject private var channelStore: ChannelStore
var body: some View { ... }
}
Model vs Form (View)
What if the data format in your model does not correspond with how you want to show it on screen? Depends on your needs. From my experience I find using local state (1.2) for form data, then convert to your model the best approach. Form should follow the Model. Remember View = f(Model).
struct RegistrationInfo: Codable {
var name: String = ""
var email: String = ""
var phone: String = ""
var age: Int = 19
func submit() async throws { ... }
}
// Convenience if needed
extension RegistrationInfo {
var firstName: String { String(name.split(separator: " ").first ?? "") }
var lastName: String { String(name.split(separator: " ").last ?? "") }
}
// 1.1 - Using view local state (direct), need a tricky solution
struct RegistrationForm: View {
@State private var info = RegistrationInfo()
@State private var isSubmitting: Bool = false
@State private var submitError: Error? = nil
var body: some View {
Form {
Section("Name") {
TextField("First name", text: Binding(get: { info.firstName }, set: { value, _ in ???? }))
TextField("Last name", text: Binding(get: { info.lastName }, set: { value, _ in ???? }))
}
// ...
Button("Submit") {
Task {
isSubmitting = true
do {
try await info.submit()
} catch {
submitError = error
}
isSubmitting = false
}
}
}
}
}
// 1.2 - Using view local state (indirect)
struct RegistrationForm: View {
@State private var firstName: String = ""
@State private var lastName: String = ""
@State private var email: String = ""
@State private var phone: String = ""
@State private var age: Int = 18
@State private var isSubmitting: Bool = false
@State private var submitError: Error? = nil
var body: some View {
Form {
Section("Name") {
TextField("First name", text: $firstName)
TextField("Last name", text: $lastName)
}
// ...
Button("Submit") {
Task {
isSubmitting = true
do {
let info = RegistrationInfo(name: "\(firstName) \(lastName)",
email: email,
phone: phone,
age: age)
try await data.submit()
} catch {
submitError = error
}
isSubmitting = false
}
}
}
}
}
// 2 - Using an external state, object part of your model
class Registration: ObservableObject {
@Published var firstName: String = ""
@Published var lastName: String = ""
@Published var email: String = ""
@Published var phone: String = ""
@Published var age: Int = 18
@Published var isSubmitting: Bool = false
var submitError: Error? = nil
func finish() async {
isSubmitting = true
do {
let data = RegistrationInfo(name: "\(firstName) \(lastName)",
email: email,
phone: phone,
age: age)
try await data.submit()
} catch {
submitError = error
}
isSubmitting = false
}
}
struct RegistrationForm: View {
@State private var registration = Registration()
var body: some View {
Form {
Section("Name") {
TextField("First name", text: $registration.firstName)
TextField("Last name", text: $registration.lastName)
}
// ...
Button("Submit") {
Task {
await registration.finish()
}
}
}
}
}
Who needs a ViewModel today?
Little, simple and clean code. Everything works great on iOS, iPadOS, macOS, tvOS, Unit / Integration Tests server. Very productive team with well defined responsabilities. Note: Just an example based on real SwiftUI app.
Another Example
We can have a single store for everything (not recommended for big, multi section / tab apps) or multi stores, separated by use case / data type / section / tab. Again, stores (ObservableObjects), we can call other names, are part of the model (yes we can separate from our data model). Also we can do everything in stores and not use active record pattern at all but my last experience tell me that is good to separate our data model (using a data access pattern) from state (we can call store object, business object, use case object, state object, external source of truth object, …).
This is the SwiftUI (declarative UI) approach. With MVVM pattern:
Requires middle layer and more code
Need one ViewModel for each View
Problems with shared state (e.g. EnvironmentObject)
Problems with local state (e.g. FocusState, GestureState, …)
Overall platform conflicts
Overall external data management limitations
Duplicating… SwiftUI View is a “ViewModel”
Massive ViewModels (yes can happen)