<![CDATA[Snopia]]> https://snopia.net RSS for Node Tue, 14 Apr 2026 12:31:54 GMT Tue, 14 Apr 2026 12:31:52 GMT <![CDATA[That curious Layout behavior]]> There’s a fascinating behavior in SwiftUI with the Layout protocol.

A Layout is not a View. When you create a Layout, you size and place views, but you don’t declare a body. And yet, it’s perfectly possible to use an instance of a Layout directly within the view hierarchy.

struct MyVStack: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize { /* ... */ }
 
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) { /* ... */ }
}
 
struct MyView: View {
    var body: some View {
        MyVStack {
            Text("1")
            Text("2")
        }
    }
}

If a Layout isn’t a view, it must produce a view responsible for sizing and placing its child views, as described in our Layout implementation.

// SwiftUI side
extension Layout {
    func makeView<V: View>(
        @ViewBuilder _ content: () -> V
    ) -> some View { /* ... */ }
}
 
MyVStack().makeView {
    Text("A")
    Text("B")
}

But in real SwiftUI, we’re not calling any such function. We’re simply creating an instance using our initializer.

// In SwiftUI, we don't call a function, we create an instance
MyVStack {
    Text("A")
    Text("B")
}

Wait, initializer? Where does this closure in the Layout’s initializer come from? SwiftUI is full of clever uses of Swift’s features, and this case is no exception.

You probably didn’t hear much about it when it was introduced with Swift 5.2, but there’s callAsFunction. It lets you call an instance of a type (like a struct or class) as if it were a function—provided that type implements a method named callAsFunction.

As we mentioned earlier, a Layout isn’t a view. So, it needs to be converted into one. Instead of calling makeView, SwiftUI uses callAsFunction.

extension Layout {
    func callAsFunction<V: View>(
        @ViewBuilder _ content: () -> V
    ) -> some View { /* ... */ }
}

Which, on our side, becomes:

MyVStack().callAsFunction {
    Text("A")
    Text("B")
}

The callAsFunction eliminates the boilerplate of naming the function. Swift even allows us to synthesize this:

// Parentheses become optional
MyVStack {
    Text("A")
    Text("B")
}

At first glance, this might seem like pure SwiftUI magic. But really, it removes a lot of boilerplate and lets us focus on what matters: sizing and placing views. There’s no magic here, just a clever use of Swift’s tools.

]]>
https://snopia.net/en/blog/layout-protocol-curious-behavior layout-protocol-curious-behavior Thu, 19 Mar 2026 00:00:00 GMT
<![CDATA[The Problem with Enums]]> With SwiftUI, it's possible to create your own alignment. Here's one that positions itself vertically at a third of a view's height.

extension VerticalAlignment {
    public static let third = VerticalAlignment(ThirdAlignmentId.self)
 
    enum ThirdAlignmentId: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            context.height / 3
        }
    }
}

You can then use our alignment with an HStack.

HStack(alignment: .third) {
    Text("Hello")
        .frame(height: 300)
    Text("World!")
}

This is super clever and I think it exposes a really good design case here. For years, I believed that alignments were enums. And this was because of their simplicity of use and their .something syntax.

In reality, SwiftUI's alignment system uses a polymorphism system to benefit from composition, extensibility, and decentralization.

Enums Don't Compose

In SwiftUI, there are three types of alignment:

  • VerticalAlignment
  • HorizontalAlignment
  • Alignment

This allows you to specify only the right type of alignment that you expect and detect errors at compile time.

// Doesn't make sense
// Will be caught at compile time
VStack(alignment: .bottom)

However, ZStack accepts a vertical alignment, or horizontal, or both! And the .topLeading syntax made me think for a long time that Alignment was an enum. But if that had been the case, there would have been some duplicated code somewhere because it's impossible to combine enums.

enum VerticalAlignment {
    case leading
    // ...
}
 
enum HorizontalAlignment {
    case top
    // ...
}
 
enum Alignment {
    case leading // duplicated?
    case topLeading // combined?
    // ...
}

Instead, Alignment is a struct that accepts a vertical and horizontal alignment.

struct Alignment {
    let vertical: VerticalAlignment
    let horizontal: HorizontalAlignment
}

To continue benefiting from the .topLeading syntax, SwiftUI exposes static properties that enjoy the same syntax.

extension Alignment {
    static let topLeading = Alignment(
        horizontal: .top,
        vertical: .top
    )
}

Enums Aren't Extensible

In Swift, it's impossible to add new cases to an enum because, fundamentally, enums represent a finite series of states.

If SwiftUI exposed ShapeStyle as an enum, it would be impossible for developers to create their own style.

enum ShapeStyle {
    case rect
    case circle
}

With the struct + protocol combo, it becomes possible to create new shapes outside of what SwiftUI exposes.

protocol ShapeStyle {}
 
struct Rect: ShapeStyle {}
struct Circle: ShapeStyle {}

And it's still possible to keep the .something enum syntax by using constrained extensions.

extension ShapeStyle where Self == Rect {
    static var rect: Self { Rect() }
}

Enums Are Centralized

To add logic to enums, you can use associated values and computed properties.

enum Shape {
    case rect(width: Double, height: Double)
    case circle(radius: Double)
 
    var area: Double {
        switch shape {
        case let .circle(r):
            return .pi * r * r
        case let .rectangle(w, h):
            return w * h
        }
    }
}

While this might be okay for such a simple implementation, SwiftUI's shapes are much more complex and such use leads to anti-patterns. You can't create a computed property to calculate the diameter of a circle without returning a default value for the rectangle.

enum Shape {
    case rect(width: Double, height: Double)
    case circle(radius: Double)
 
    var diameter: Double {
        switch shape {
        case let .circle(r):
            return r * 2
        case let .rectangle(w, h):
            return 0 // makes no sense
        }
    }
}

This return 0 reminds me of an abstract class where you return a default value for values meant to be overridden.

By using polymorphism, it's entirely possible to decentralize and create specific cases for each shape.

protocol Shape {
    var area: Double { get }
}
 
struct Circle: Shape {
    let radius: Double
 
    var area: Double {
        .pi * radius * radius
    }
 
    // This is possible and won't be implemented in Rect
    var diameter: Double {
        radius * 2
    }
}

Constrained extensions work here too to keep the .something syntax.

extension Shape where Self == Circle {
    static func circle(radius: Double) -> Self {
        Circle(radius: radius)
    }
}

Enums Are Too Simple

Enums are the choice to favor in most cases. But when you need to compose, extend, or decentralize, it's imperative to consider another approach.

We love the .something syntax of enums. But it's not exclusive to them! Constrained extensions and static properties also benefit from this behavior.

]]>
https://snopia.net/en/blog/the-problem-with-enums the-problem-with-enums Thu, 22 Jan 2026 00:00:00 GMT
<![CDATA[A More Accessible NetworkKit]]> The first feedback on NetworkKit has been encouraging! The repo currently has 34 stars. A number that grows each week. I've also received some feedback that helped me think about version 1.2.0, available today!

Response typing

Until now, the response type was inferred by the type of the variable declared at the time of perform.

// Compiles with the type
let users: Response<[Users]> = try await client.perform(.getUsers)
 
// Doesn't compile without the type
// Generic parameter 'T' could not be inferred
let users = try await client.perform(.getUsers)

I generally prefer to explicitly type variables. But I have to admit that this behavior goes against NetworkKit's simplicity of use. Especially since you must add the generic type Response<>, which is specific to the package.

Faced with this observation, I quickly published a minor update that allowed associating the response type directly to the request.

@Get("/users", of: [User].self)
struct GetUsers {
    // The @Get macro adds the associated type
    // typealias Response = [Users]
}
 
// Now works without explicit type
let users = try await client.perform(.getUsers)

This method works, but I was quite unsatisfied with the syntax. Sometimes you have a response type that's specific to a single request. In this case, we'd like, as with @Body, to declare the type only in the request like this:

@Get("/users", GetUsers.ResponseDto.self)
struct GetUsers {
    struct ResponseDto: Decodable {
        let id: String
        let name: String
    }
}

But we end up with a longer and more complicated syntax. The complete opposite of what NetworkKit is supposed to solve.

The @Response macro

Like the @Body macro, the @Response macro brings a simple and elegant solution to this problem. It's now possible to declare the response type internally:

@Get("/user/:id")
struct GetUser {
    @Path
    let id: String
 
    @Response
    struct UserDto {
        let id: String
        let name: String
    }
}

Or to reuse an existing type like this:

@Get("/users")
@Response([User].self)
struct GetUsers { }

The syntax is now much clearer and more flexible!

Documentation

Speaking of accessibility, documentation is the entry point for devs to your package. Don't neglect it!

I've strengthened the package documentation overall and I've also published the DocC format documentation.

NetworkKit's DocC DocC format documentation allows you to quickly guide a dev on how to use your tool.

It contains some articles that help you quickly get started using the package as well as explanations of NetworkKit's references. Don't hesitate to give me feedback!

It's time to try it

I've been using NetworkKit professionally in large projects for several years and I've never been more satisfied with its efficiency. Maybe it's time to try it?

]]>
https://snopia.net/en/blog/more-accessible-networkkit more-accessible-networkkit Wed, 08 Oct 2025 00:00:00 GMT
<![CDATA[Just another network framework?]]> I've never been able to find a network framework that's simple to use for both small projects and large projects that grow quickly. Some like URLSession and Alamofire are low-level and always require setting up an architecture around them. Others like Moya have attempted more user-friendly approaches, but struggle to organize cleanly in larger projects.

With the arrival of macros in Swift, clever developers have tried protocol-based approaches. A vision that makes the most of macros, but too inflexible in my opinion.

How to get the best of both worlds? A framework that's both simple to get started with but flexible for the most important needs. It's for these reasons that I designed NetworkKit.

  • Simple, flexible, and modern syntax.
  • A distinction between request and client.
  • Middleware, interceptor, logger.
  • Completely customizable. Perfect for both small and large projects.

Let me show you the basics of NetworkKit.

@Get("/users/:id/books")
struct GetBooks {
    @Path
    var id: String
 
    @Query
    let limit: Int
}
 
let request = GetBooks(id: "1234", limit: 10)
let client = Client("https://api.com")
 
let response: Response<[Book]> = try await client.perform(request)
let books = response.data

A declarative approach

NetworkKit uses a declarative approach for requests. It's intuitive, readable, and has a what you see is what you get feel. Using macros allowed me to achieve this kind of result:

@Get("/users/:id/books")
struct GetUserBooks {
    @Path
    var id: String
 
    @Query("s")
    let search: String
}

This syntax allows you to quickly build simple requests while maintaining good readability on more complex requests.

@Post("/users/:id/books")
struct PostUserBooks {
    @Path
    var id: String
 
    @Body
    struct Body {
        let name: String
        let author: Author
    }
}

An example of a POST request with a body.

Separating client and request

It's very common to work with multiple development environments (dev, staging, prod, etc.). Your server changes, but your requests stay the same. That's why NetworkKit separates the client from requests. It's thus possible to send the same request to your dev and prod servers, without having to declare the same thing twice.

@Get("/users/:id/books")
struct GetBooks {
    @Path
    let id: String
}
 
let request = GetBooks(id: "1234")
 
let dev = Client("https//localhost:3000")
let prod = Client("https://api.com")
 
let devResponse: Response<[Book]> = try await dev.perform(request)
let prodResponse: Response<[Book]> = try await prod.perform(request)

With this method, nothing is declared twice.

Moreover, on large projects, you can easily end up with hundreds of requests. When projects grow, organizing things by file in folders is very effective. With this approach, you can use Xcode's quick search to quickly access your requests.

Can it be adopted?

With the previous version of NetworkKit, I was hoping for some feedback. But I didn't get any. And that's completely normal. It's clear that for a developer to choose your framework over another more popular one, it had better be really solid. Over time, I was able to take a step back on how I was using it. Here are the points I worked on:

  • Improve the declarative syntax. I hope the simple but complex aspect will have its effect.
  • A more complete lifecycle for requests with middlewares and interceptors.
  • Strengthen the existing: make what already existed even simpler and more permissive.
  • Much better documentation.

I've been using NetworkKit since its beginnings and the new version for some time in my personal projects as well as with my clients. I'm convinced that this approach is reliable and it has proven capable of scaling on large projects.

If you're curious and want to test NetworkKit, you can find all installation instructions on the Github project. Don't hesitate to give me feedback!

]]>
https://snopia.net/en/blog/just-another-network-framework just-another-network-framework Fri, 18 Jul 2025 00:00:00 GMT
<![CDATA[Loading. Error. All Good.]]> Years of building apps have taught me to create reusable components to prototype and build faster. One thing that keeps coming back: handling state when fetching asynchronous data.

3 screens with loading, data, and error states Three screens you see all the time.

After some experimentation, I landed on this data structure:

@MainActor
@Observable
final class DataState<T> {
    enum State {
        case loading
        case loaded(T)
        case error(Error)
    }
 
    var state: State
 
    init(_ state: State) {
      self.state = state
    }
}

This DataState can be used like this:

@MainActor
@Observable
final class ViewModel {
    var user: DataState<User> = DataState(.loading)
 
    func loadUser() async {
        user.state = .loading
        do {
            try await Task.sleep(for: .seconds(1))
            user.state = .loaded(User())
        } catch {
            user.state = .error(error)
        }
    }
}

And displayed like this:

struct ContentView: View {
    @State private var viewModel = ViewModel()
 
    var body: some View {
        switch viewModel.user {
            case .loading:
                ProgressView()
                    .task {
                        await viewModel.loadUser()
                    }
            case let .loaded(user):
                Text(user.name)
            case let .error(error):
                Text(error.localizedDescription)
        }
    }
}

Okay, we have something reusable, but we've clearly lost some readability and simplicity. That's because we introduced a new data structure without moving the business logic into it.

To fix this, let's first create a new view to encapsulate the switch logic.

struct DataStateView<T, Content: View>: View {
    private let dataState: DataState<T>
    private let makeContent: (T) -> Content
 
    init(
         _ dataState: DataState<T>,
        @ViewBuilder content: (_ data: T) -> Content
    ) {
        self.dataState = dataState
        self.makeContent = content
    }
 
    var body: some View {
        switch dataState.state {
            case .loading:
                ProgressView()
            case let .loaded(data):
                makeContent(data)
            case let .error(error):
                Text(error.localizedDescription)
        }
    }
}

Now we can simplify our main view by using DataStateView instead of the switch:

struct ContentView: View {
    @State private var viewModel = ViewModel()
 
    var body: some View {
        DataStateView(viewModel.user) { user in
            Text(user.name)
        }
        .task {
            await viewModel.loadUser()
        }
    }
}

Much better! Now, let's make the initialization of a DataState more convenient.

extension DataState {
    static var loading: DataState {
        .init(state: .loading)
    }
 
    static func loaded(_ data: T) -> DataState {
        .init(state: .loaded(data))
    }
 
    static func error(_ error: Error) -> DataState {
        .init(state: .error(error))
    }
}

Now we can initialize our DataState like this:

// Before
var user: DataState<User> = DataState(.loading)
// After
var user: DataState<User> = .loading

Much better! But there's still a repeating pattern in our view model:

user.state = .loading
do {
    try await Task.sleep(for: .seconds(1))
    user.state = .loaded(User())
} catch {
    user.state = .error(error)
}

When fetching async data, we always go through the same sequence:

  1. Set to loading.
  2. Run async code to fetch data.
  3. If there's no error, set the data. Otherwise, set to error.

Let's move this sequence into our DataState!

extension DataState {
    func load(_ loadData: @MainActor () async throws -> T) async {
        state = .loading
        do {
            let data: T = try await loadData()
            state = .loaded(data)
        } catch {
            state = .error(error)
        }
    }
}

Which simplifies our code even more! Here's the final result:

@MainActor
@Observable
final class ViewModel {
    let user: DataState<User> = .loading
 
    func loadUser() async {
        await user.load {
            try await Task.sleep(for: .seconds(1))
            return User()
        }
    }
}
 
struct ContentView: View {
    @State private var viewModel = ViewModel()
 
    var body: some View {
        DataStateView(viewModel.user) { user in
            Text(user.name)
        }
        .task {
            await viewModel.loadUser()
        }
    }
}

Much simpler, more readable, and reusable!

Going further

There's always room to improve. It's definitely possible (and even recommended) to make the error and loading states of DataStateView customizable. You could imagine a syntax like this:

DataStateView(user) { user in
    Text(user.name)
} loading: {
    ProgressView()
} error: { error in
    Text(error.localizedDescription)
}

You could imagine going even further by creating a property wrapper to encapsulate our DataState:

@MainActor
@Observable
@propertyWrapper
final class AsyncData<T> {
    var wrappedValue: T? {
        get {
            guard case let .loaded(data) = dataState.state else {
                return nil
            }
            return data
        }
        set {
            dataState.state = .loaded(newValue)
        }
    }
 
    var projectedValue: DataState<T?> {
        dataState
    }
 
    private let dataState: DataState<T?>
 
    init(_ wrappedValue: T? = nil) {
        if let data = wrappedValue {
            self.dataState = .loaded(data)
        } else {
            self.dataState = .loading
        }
    }
}

This would enable an even more elegant syntax:

@MainActor
@Observable
final class ViewModel {
    @AsyncData var user: User?
 
    func loadUser() async {
        await $user.load {
            try await Task.sleep(for: .seconds(1))
            return User()
        }
    }
}

Unfortunately, this approach doesn't work with @Observable. The @Observable macro doesn't support custom property wrappers, as it needs direct access to properties to handle observation of changes.

How do you handle async loading logic in your apps? Share your ideas or feedback with me on Mastodon or Bluesky!

]]>
https://snopia.net/en/blog/data-state data-state Mon, 23 Jun 2025 00:00:00 GMT
<![CDATA[Creating SwiftUI-Style Components]]> So I needed to manage states in my app once again. I decided it was time to finally address this issue properly and create a package that I could reuse whenever I wanted.

As always, it all starts with simple experiments. I analyze the problems and try to push the solution to its extreme in order to achieve the best possible syntax.

Requirements

  • States that are independent and can lead to other states.
  • The ability to know, in a state, when it is active or inactive.
  • A state machine that encapsulates the current state.

Let's take a simple enough example: 2 states, on and off, that automatically switch from one to the other every n seconds. A first trivial approach allows us to imagine the state machine like this:

@MainActor
protocol StateMachineState: Sendable {
    func enter(stateMachine: StateMachine) async
    func exit(stateMachine: StateMachine) async
}
 
extension StateMachineState {
    func enter(stateMachine: StateMachine) async { }
    func exit(stateMachine: StateMachine) async { }
}
 
@MainActor
final class StateMachine {
    var currentState: StateMachineState
 
    init(initialState: StateMachineState) {
        currentState = initialState
    }
 
    func start() async {
        await currentState.enter(stateMachine: self)
    }
 
    func transition(to newState: StateMachineState) async {
        await currentState.exit()
        currentState = newState
        await currentState.enter(stateMachine: self)
    }
}

From there, we can build our states:

struct OnState: StateMachineState {
    let delay: TimeInterval
		
    func enter(stateMachine: StateMachine) async {
        print("ON")
        Task {
            try? await Task.sleep(for: .seconds(delay))
            await stateMachine.transition(to: OffState(delay: delay))
        }
    }
}
 
struct OffState: StateMachineState {
    let delay: TimeInterval
		
    func enter(stateMachine: StateMachine) async {
        print("OFF")
        Task {
            try? await Task.sleep(for: .seconds(delay))
            await stateMachine.transition(to: OnState(delay: delay))
        }
    }
}

And finally, use our state machine like this:

let stateMachine = StateMachine(initialState: OffState(delay: 1))
await stateMachine.start()

This implementation meets the requirements. The states are independent, the state machine encapsulates the current state, and the enter() and exit() functions allow states to be notified when they are active.

So what's wrong?

Although this approach is completely functional, it is actually prone to several possible human errors.

First, the enter() and exit() functions have the entire state machine as a parameter. It's theoretically possible for a developer to accidentally call functions other than transition(to:) and thus break the functionality.

struct OnState: StateMachineState {
    func enter(stateMachine: StateMachine) async {
        // This is technically useless, but it's possible.
        await stateMachine.start()
    }
}

Next, the parameters of the states are always independent. This is a good thing in some cases. But in others, like here, we would like to declare only one delay. Once again, it's not out of the question that the developer might change the delay of the on state without modifying that of the off state.

Finally, if the developer keeps a reference to the machine in a state, they must remember to declare the machine as weak. And you know as well as I do that "they must remember" eventually translates to "they forgot."

A SwiftUI approach

The @Environment property wrapper can be our source of inspiration to improve our component. In the same way that the SwiftUI environment can propagate to child views, it would be interesting for an environment to propagate to our states.

Let's try to imagine the most optimal syntax and see what we can do to achieve it.

struct Context: StateMachineContext {
    let delay: TimeInterval
}
 
struct OffState: StateMachineState {
    @StateTransition private var transition
    @StateContext(Context.self) private var context
		
    func enter() async {
        Task {
            try? await Task.sleep(for: .seconds(context.delay))
            await transition(to: OnState())
        }
    }
}
 
let stateMachine = StateMachine(initialState: OffState())
    .context(Context(delay: 1))

There are quite a few changes. Don't panic: let's break down this difficult problem into several small, easy-to-solve problems.

It’s all about injection

The first question that probably comes to your mind when seeing this: how can we retrieve data via a property wrapper without going through an initializer?

The trick is to wrap our state and inject the necessary data using Mirror. Mirrors in Swift allow us to dynamically explore the internal structure of an object, even if its properties are private. Thus, we can access the property wrapper and inject the necessary data.

struct StateMachineStateWrapper<Context: StateMachineContext>: Sendable {
    let state: StateMachineState
    let context: Context
 
    @MainActor
    func makeInjection() {
        let mirror: Mirror = .init(reflecting: state)
        for child in mirror.children {
            if let propertyWrapper = child.value as? StateContext<Context> {
                propertyWrapper.inject(context: context)
            }
        }
    }
}

Here's what our @StateContext looks like:

@MainActor
@propertyWrapper
public final class StateContext<Context: StateMachineContext> {
    public var wrappedValue: Context {
        guard let context = context else {
            print("Accessing @StateContext within a state that is not managed by a StateMachine.")
            return Context.defaultValue
        }
        return context
    }
 
    private var context: Context?
 
    public nonisolated init(_ context: Context.Type) { }
 
    func inject(context: Context) {
        self.context = context
    }
}

The message displayed if the context has not yet been injected should remind you of something. If you've ever tried to modify a property annotated with @State in SwiftUI in a view that isn't installed, you must have received this warning:

Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.

It's exactly the same concept here. If the context hasn't been injected yet, it means our state isn't encapsulated in a StateMachineStateWrapper and therefore isn't managed by the state machine. This is similar to how SwiftUI is able to detect within a View if it is installed in the hierarchy. Clever!

Regarding the @StateTransition property wrapper, we can use the same injection mechanism. This time we inject a StateTransitionHandler that will handle the link between the state and the state machine.

@MainActor
@propertyWrapper
public final class StateTransition {
    public var wrappedValue: StateTransitionHandler {
        guard let transitionHandler = transitionHandler else {
            assertionFailure("Accessing @StateTransition within a state that is not managed by a StateMachine.")
            return .init()
        }
        return transitionHandler
    }
 
    private var transitionHandler: StateTransitionHandler?
 
    public nonisolated init() { }
 
    func inject(transitionHandler: StateTransitionHandler) {
        self.transitionHandler = transitionHandler
    }
}
@MainActor
public final class StateTransitionHandler {
    typealias Handler = @Sendable (_ newState: StateMachineState) async -> Void
 
    private var transitionHandler: Handler?
 
    public func callAsFunction(to newState: StateMachineState) async {
        await transitionHandler?(newState)
    }
 
    func onTransition(_ handler: @escaping Handler) {
        self.transitionHandler = handler
    }
}

Bonus point: callAsFunction allows us to directly use the transition property and call it as a function. The syntax is even simpler!

@StateTransition private var transition
 
// Without callAsFunction
await transition.transition(to: OnState())
// With callAsFunction
await transition(to: OnState())

All that's left is to coordinate everything in the state machine.

protocol StateMachineContext: Sendable {
    static var defaultValue: Self { get }
}
 
struct EmptyStateMachineContext: StateMachineContext {
    static let defaultValue: Self = .init()
}
@MainActor
public final class StateMachine<Context: StateMachineContext> {
    public private(set) var currentState: StateMachineState
 
    private let initialState: StateMachineState
    private let context: Context
    private let transitionHandler: StateTransitionHandler = .init()
    private var currentStateWrapper: StateMachineStateWrapper<Context>
 
    private nonisolated init(
        initialState: StateMachineState,
        context: Context
    ) {
        self.initialState = initialState
        self.context = context
        self.currentStateWrapper = .init(
            state: initialState,
            transitionHandler: transitionHandler,
            context: context
        )
    }
 
    public func start() async {
        transitionHandler.onTransition { [weak self] newState in
            guard let self else { return }
            await self.transition(to: newState)
        }
        currentStateWrapper.makeInjection()
        await initialState.enter()
    }
 
    public func transition(to newState: StateMachineState) async {
        await currentState.exit()
        currentState = newState
        currentStateWrapper.makeInjection()
        await currentState.enter()
    }
}

To get our syntax that allows us to inject our environment, we can use conditional extensions.

public extension StateMachine where Context == EmptyStateMachineContext {
    convenience nonisolated init(initialState: StateMachineState) {
        self.init(
            initialState: initialState,
            context: .init()
        )
    }
    
    func context<C: StateMachineContext>(_ context: C) -> StateMachine<C> {
        .init(
            initialState: initialState,
            context: context
        )
    }
}

In summary

Here is the final syntax of our state machine.

struct Context: StateMachineContext {
    let delay: TimeInterval
}
 
struct OffState: StateMachineState {
    @StateTransition private var transition
    @StateContext(Context.self) private var context
		
    func enter() async {
        Task {
            try? await Task.sleep(for: .seconds(context.delay))
            await transition(to: OnState())
        }
    }
}
 
let stateMachine = StateMachine(initialState: OffState())
    .context(Context(delay: 1))

In addition to being pretty cool, this syntax is much less prone to errors.

  • The state machine is no longer directly referenced in the enter() function. It's no longer possible to break the functionality of the machine from a state or to forget a weak. Goodbye memory leaks by forgetfulness.
  • A context can be declared globally in all states. Changing a parameter applies it to all states automatically.

The icing on the cake is that this syntax helps us better understand the black magic of SwiftUI.

To conclude, StateMachine is available as a Swift package on my GitHub. Feel free to take a look to explore the subject further!

]]>
https://snopia.net/en/blog/create-swiftui-style-components create-swiftui-style-components Tue, 11 Mar 2025 00:00:00 GMT
<![CDATA[Open sourcing Storma 2.0]]> I’ve started developing Storma 2.0, and my to-do list is packed! Here are some of the features I’m hoping to deliver:

  • A better iPad experience.
  • Mac and Apple Watch versions.
  • Optimized map performance.
  • The ability to look back in time and track storm paths.
  • Customizable alert radius settings.

Creating a Mac version means improving the iPad experience, which I currently find underwhelming. I initially relied on the standard NavigationSplitView and .inspector() components, but they didn’t integrate well with a map-focused interface. So, I decided to build my own NavigationCard component. It mirrors the functionality of NavigationStack but adapts to show cards on iPad and sheets on iPhone.

NavigationSplitView iPad The current iPad version using NavigationSplitView and .inspector().

NavigationCard iPad The iPad version of Storma 2.0 with NavigationCard, offering a more cohesive navigation experience.

NavigationCard iPhone The iPhone version of Storma 2.0 with NavigationCard, featuring sheet-based navigation without extra code.

struct ContentView: View {
    @State private var path: CardPath = .init()
 
    var body: some View {
        NavigationCard(path: $path) {
            strikeMap
        } card: {
            alertList
                .cardTitle("Alerts")
                .cardToolbar {
                    CardDismissButton()
                }
                .cardDestination(for: Alert.self) { alert in
                    AlertDetailView(alert: alert)
                }
        }
    }
}

Apple Watch Challenges

Developing the Apple Watch app has been a challenge. The iOS app struggles in areas with heavy storm activity, and the Apple Watch’s capabilities are more limited than the iPhone’s. I had to rewrite the map entirely to make it as performant as possible.

Storma on Apple Watch Storma 2.0 on Apple Watch with enhanced performance.

To achieve this, I used a quadtree algorithm. While there are several open-source implementations in Swift, I chose to write my own, ensuring it’s generic and fully compatible with Swift 6.

// Define a quadtree that stores `Strike` and uses `MKCoordinateRegion` as the boundary.
// `Strike` conforms to `QuadtreeElement`.
// `MKCoordinateRegion` conforms to `QuadtreeRect`.
let quadTree: QuadTree<Strike, MKCoordinateRegion> = .init(
    boundary: .world,
    capacity: 10
)
 
quadtree.insert(strike) // Insert a strike in the quadtree
let strikes = quadtree.query(region) // Query all strikes in a given region

Going Open Source

The biggest news? I’m making these components open source! Sticker, NavigationCard, and Quadtree are the core of Storma, and they’ll soon be freely available on my GitHub.

I’ve thought about open-sourcing an app for a long time but always hesitated, fearing my code might be stolen. With Storma, it’s different. The app is free and relies on open-source licensed data that prohibits commercial use.

While the future remains uncertain, I believe the benefits outweigh the risks:

  • It invites contributions from other developers, enriching these projects with their ideas and expertise.
  • It allows potential clients to explore my coding style and project organization.
  • It pushes me to modularize my code even more.

Sticker is already available (I talked about it in a previous article). NavigationCard and Quadtree will follow soon. Stay updated by following me on Bluesky and Mastodon, or subscribe to the RSS feed!

]]>
https://snopia.net/en/blog/opens-ourcing-storma-2 opens-ourcing-storma-2 Thu, 02 Jan 2025 00:00:00 GMT
<![CDATA[Interfaces Are Gaining Weight]]> As the year-end approaches, so do those resolutions we barely keep. Forget all of that. No more two-week diets. This time, it's your interfaces that are putting on weight.

In 2007, skeuomorphism was all the rage, bringing ultra-realistic interfaces. Then, a few years later, iOS 7 sparked a revolution with its flat design.

Today, we find ourselves much closer to flat design than skeuomorphism. But as opposites attract, my interfaces have started to gain some weight—adding a touch of fun and vibrancy in the process.

Here are some juicy, well-stuffed components.

The Candy Button

A round, plump button with an outer bulge and a slightly sunken shape, ready to hug your finger when tapped.

Candy Button
No, this login button won't ask for your password.

The magic of this effect lies in two key details:

  • A slightly darkened gradient at the top gives the button a sunken look.
  • A white-to-black gradient overlay, lightly blurred, creates the bulge effect, adding depth.

The best part? It works beautifully with a variety of colors.

Candy Button
Delicious.

struct CandyButtonStyle: ButtonStyle {
    @Environment(\.colorScheme) private var colorScheme
 
    private let cornerRadius: CGFloat = 16
 
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .padding(.vertical)
            .background {
                LinearGradient(
                    colors: configuration.isPressed ? [
                        .accent.mix(with: .black, by: colorScheme == .light ? 0.3 : 0.5),
                        .accent.mix(with: .white, by: 0.1)
                    ] : [
                        .accent.mix(with: .black, by: 0.14),
                        .accent
                    ],
                    startPoint: .top,
                    endPoint: .bottom
                )
                .clipShape(.rect(cornerRadius: cornerRadius))
                .shadow(
                    color: .black.opacity(0.2),
                    radius: 4,
                    y: 3
                )
            }
            .overlay {
                RoundedRectangle(cornerRadius: cornerRadius - 1)
                    .stroke(LinearGradient(
                        colors: [.white.opacity(0.6), .black.opacity(0.1)],
                        startPoint: .top,
                        endPoint: .bottom
                    ), lineWidth: 1)
                    .padding(1)
                    .blur(radius: 1)
            }
    }
}

The Crunchy Card

This rigid, paper-like card feels solid and tangible.

Crunchy Card

  • A subtle shadow is enough to add a touch of relief.
  • Adding a darker border (#ffffff12) gives the card some extra volume.
RoundedRectangle(cornerRadius: 16)
    .fill(.background)
    .overlay {
        RoundedRectangle(cornerRadius: 16)
            .stroke(.black.opacity(0.12))
    }
    .frame(width: 100, height: 100)
    .shadow(
        color: .black.opacity(0.1),
        radius: 10,
        y: 4
    )

The Gummy Glass

A variant of the Crunchy Card, this one comes with a blurred background.

Gummy Glass For the nostalgic among you: that background is a gift.

  • A white-to-black gradient, just like the button, adds relief to the card.
  • Despite the blur effect, a soft shadow enhances realism without overdoing it.
RoundedRectangle(cornerRadius: 16)
    .fill(.regularMaterial)
    .overlay {
        RoundedRectangle(cornerRadius: 16)
            .stroke(
                LinearGradient(
                    colors: [
                        .white.opacity(0.5),
                        .black.opacity(0.1)
                    ],
                    startPoint: .top,
                    endPoint: .bottom
                ),
                lineWidth: 2
            )
            .blur(radius: 1)
    }
    .clipShape(.rect(cornerRadius: 16))
    .frame(width: 100, height: 100)
    .shadow(color: .black.opacity(0.2), radius: 20, y: 4)

Tips for Adding Weight

There are countless design labels out there, but don’t focus on ticking boxes. What matters most is your style and maintaining consistency across your work.

To give your interfaces some weight, remember:

  1. Think like a photographer: Start with a flat element and imagine it as a real object captured in your environment.
  2. Imperfections add charm: In real life, nothing is perfectly square or smooth. Use blur tools to soften overly unreal designs.
  3. Shadows are your friend: They’re a quick way to add volume, but handle with care. Inconsistent shadows between elements can make your design feel cheap.

Now it’s your turn—time to create the best Bouncy Switch ever!

]]>
https://snopia.net/en/blog/interfaces-are-gaining-weight interfaces-are-gaining-weight Tue, 26 Nov 2024 00:00:00 GMT
<![CDATA[Recipe: Sticker Effect in SwiftUI]]> I was once again experimenting with shaders and SwiftUI. What started as a small test for version 2 of Storma ended up as an open-source Swift package.

Sticker effect on a lightning-shaped icon

The result: Sticker, a tool that lets you add a "Pokémon card" effect to any view. Available now on my GitHub, this package is easy to use. If you like the result, give it a try, share your feedback, and feel free to drop a star on the repo. ⭐

While developing Sticker, I aimed to make shaders more accessible, as shader code can be daunting. If you're just starting, I highly recommend experimenting with visual editors like Godot or Unity, where you can connect blocks visually, making the process much easier to grasp. For the curious, here’s how you can recreate this effect step by step.

1. Create a Color Gradient

Start by writing a pseudo-random function based on pixel positions.

// Generate pseudo-random noise
float random(float2 uv) {
    return fract(sin(dot(uv.xy, float2(12.9898, 78.233))) * 43758.5453);
}

Then use sine and cosine to add some dynamic shifts to the gradient.

float2 uv = position;
float contrast = 0.9;
float gradientNoise = random(position) * 0.1;
 
half r = half(contrast + 0.25 * sin(uv.x * 10.0 + gradientNoise));
half g = half(contrast + 0.25 * cos(uv.y * 10.0 + gradientNoise));
half b = half(contrast + 0.25 * sin((uv.x + uv.y) * 10.0 - gradientNoise));
 
half4 foilColor = half4(r, g, b, 1.0);

2. Add a Diamond Grid

To enhance realism, create a grid of squares and rotate it 45°.

// Checker pattern function for a diamond grid effect
float checkerPattern(float2 uv, float scale, float degreesAngle) {
    float radiansAngle = degreesAngle * M_PI_F / 180;
 
    // Scale UV coordinates
    uv *= scale;
 
    // Rotate UV coordinates by the given angle
    float cosAngle = cos(radiansAngle);
    float sinAngle = sin(radiansAngle);
    float2 rotatedUV = float2(
        cosAngle * uv.x - sinAngle * uv.y,
        sinAngle * uv.x + cosAngle * uv.y
    );
 
    // Determine the tile color (black or white)
    return fmod(floor(rotatedUV.x) + floor(rotatedUV.y), 2.0) == 0.0 ? 0.0 : 1.0;
}

3. Add Grain Texture

To add texture to your effect, generate some noise.

float noisePattern(float2 uv) {
    float2 i = floor(uv);
    float2 f = fract(uv);
 
    // Four corners of a tile in 2D
    float a = random(i);
    float b = random(i + float2(1.0, 0.0));
    float c = random(i + float2(0.0, 1.0));
    float d = random(i + float2(1.0, 1.0));
 
    // Smooth interpolation
    float2 u = smoothstep(0.0, 1.0, f);
 
    // Mix percentages of the four corners
    return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}

4. Prepare to Mix

Define a function to calculate a color’s brightness.

// Helper function to calculate brightness
float calculateBrightness(half4 color) {
    return (color.r * 0.299 + color.g * 0.587 + color.b * 0.114);
}

Create a utility function to blend two colors based on the brightness of the base color.

// Function to blend colors with more intensity for brighter areas
half4 lightnessMix(half4 baseColor, half4 overlayColor, float intensity, float baselineFactor) {
    float brightness = calculateBrightness(baseColor);
 
    float adjustedMixFactor = max(smoothstep(0.2, 1.0, brightness) * intensity, baselineFactor);
 
    return mix(baseColor, overlayColor, adjustedMixFactor);
}

Lastly, define a function to increase contrast.

// Function to enhance contrast using a pattern value
half4 increaseContrast(half4 source, float pattern, float intensity) {
    float brightness = calculateBrightness(source);
 
    float contrastFactor = mix(1.0, intensity, pattern * brightness);
 
    half4 contrastedColor = (source - half4(0.5)) * contrastFactor + half4(0.5);
 
    return contrastedColor;
}

5. Mix It All Together

Combine everything in the following order:

  1. Blend the input color with your gradient, taking input brightness into account.
  2. Add the diamond grid effect using a contrast-increasing mix.
  3. Add the noise texture with another contrast-increasing mix.
half4 mixedFoilColor = lightnessMix(color, foilColor, intensity, 0.3);
half4 checkerFoil = increaseContrast(mixedFoilColor, checker, checkerIntensity);
half4 noiseCheckerFoil = increaseContrast(checkerFoil, noise, noiseIntensity);
 
return noiseCheckerFoil;

The result is a more pronounced effect on bright colors and a subtle shimmer on darker colors, adding realism to your design.

Comparison of icons with and without the effect Comparison of icons with and without the effect. Notice the stronger effect on lighter colors.

Feel free to experiment and let me know what you create with it!

]]>
https://snopia.net/en/blog/recipe-sticker-effect-swiftui recipe-sticker-effect-swiftui Tue, 12 Nov 2024 00:00:00 GMT
<![CDATA[Sell Solutions, Not Features]]> We’ve all met someone who could sell just about anything with their gift of gab. For me, that person is Artem, the iOS developer who took over after me at MacG.

Artem is the guy who’ll secure your phone to your bike with a clever little accessory. The guy who’ll let you control music from your MacBook Pro’s notch. The guy who knows how to give a proper demo because he gets the real need behind the product.

That’s the key: sell solutions, not features.

When you sell features, you’re not answering the all-important “What’s in it for me?” question that every user has. And if you’re like me, excitedly presenting your shiny new features, you’ll often end up justifying yourself mid-demo—an uncomfortable place to be. And let’s face it, demos are already nerve-wracking enough without adding unnecessary friction.

Keeping this in mind, I decided to completely rethink the App Store screenshots for SuperText.

Les première version des captures d'écran de SuperText. The screenshots from SuperText’s initial launch. None of them really answer the “What’s in it for me?” question.

Imagine the user’s internal monologue while looking at these:

  • "Powerful and easy to use." – Okay, but doesn’t my phone already have a spell checker?
  • "Understand the corrections made to your text." – Hmm… but I hardly ever make mistakes.
  • “Speaks multiple languages.” – Interesting. But does it work in French?

Screenshots are like demos: you can’t leave room for doubt. Here’s the revamped version:

Les captures d'écran de SuperText après la refonte.

  • "Write without mistakes" – Okay, but for what?
  • "Seize opportunities" – True, writing a cover letter without errors is such a pain.
  • "Perfect your articles" – That could be useful for my daughter’s blog.
  • "Enhance your documents" – If this works for emails, it might be worth it!
  • "Learn from your mistakes" – Alright, not bad!
  • "Speaks your language" – So it works in French too.
  • "The ultimate proofreader" – Wow, it does all that?

See the difference? These arguments leave no room for uncertainty. They place the user at the center, presenting scenarios they can relate to.

Sure, the saying goes, “Don’t judge a book by its cover.” But on the App Store, it’s the cover that users see first. Sell solutions, not features.

]]>
https://snopia.net/en/blog/sell-solutions-not-features sell-solutions-not-features Mon, 14 Oct 2024 00:00:00 GMT
<![CDATA[Making of: The SuperText Icon]]> Releasing a new app on the App Store is both incredibly exciting and nerve-wracking. SuperText was no exception. But unlike my previous apps, I found myself days away from launch without a satisfying icon. After a redesign, however, the SuperText icon became one of my proudest creations.

As a developer, I tend to focus heavily on fine-tuning my app’s interface to provide the best user experience possible. But before users can even experience the app, they need to download it—and the first thing they’ll notice on the App Store is the icon (well, maybe the screenshots too, but that’s a topic for another post). Designing an app icon is more than just creating a logo for your iPhone’s home screen; it’s a crucial marketing tool.

Creating the SuperText icon wasn’t easy.

Step 1: Keywords First

Brainstorming keywords that represent the app is my first step. It helps me quickly explore initial concepts and set a creative direction. For SuperText, I identified these key ideas:

  • Spell-checking
  • Artificial intelligence
  • Text
  • Simplicity
  • Speed
  • Magic

Step 2: Seek Inspiration

Next, I dive into what’s already out there, exploring the best of the best in design. Hello, Dribbble! I search for my keywords one by one. The results are diverse and often vague, which is perfect for drawing inspiration from a variety of creative works. I scroll, take notes, and admire the incredible talent and ideas on display.

Step 3: Prototyping

Now comes the fun (and often frustrating) part: sketching and prototyping. The challenge this time? Unlike my earlier apps, the keywords for SuperText weren’t tied to easily visualized physical objects. RPN Calculator? A calculator. Storma? Lightning bolts. But “AI,” “text,” or “spell-checking”? Those are much harder to represent. Time to think outside the box.

I started with the most relatable keyword: text.

Les premiers prototypes If an AI designed a magical text-processing app icon, it might look as uninspired and soulless as these. The chrome effect on the second icon was a total failure.

Icônes en forme de touche de clavier This idea seemed promising, but the symbols were too small to be recognizable.

When those attempts didn’t work, I shifted to another keyword: magic. Sometimes, drastically changing direction is the best way forward.

Incantations magiques ! This icon had a bold personality but strayed too far into “witchcraft” territory, losing its connection to spell-checking.

Let’s be honest: the results weren’t great. At this point, why not try something entirely different?

Mascotte A mascot can make an icon more approachable, but it’s hard to connect this design to the app’s concept.

My initial attempts didn’t lead anywhere. Meanwhile, the app’s development was progressing rapidly, and I was ready to release version 1.0—without a proper icon. I had to make a quick decision. Forget the dream icon; I settled for this:

Dernière itérations. The first one: too plain. The second: blatantly copied from Dribbble. The third: more original but still overly simplistic.

I went with the third icon for version 1, knowing it wasn’t ideal but leaving room for improvement.

Round Two

After the app launched, I had time to step back and approach the icon with fresh eyes. Taking a break and gaining perspective is invaluable when you’re stuck.

For no particular reason, I decided to focus on the “magic wand” concept. Instead of a flat design, I aimed for a semi-realistic style inspired by the shiny moons in Super Mario Odyssey. Their gleaming, eye-catching effect felt perfect.

Une lune de Super Mario Odyssey Bright, shiny objects in the game are designed to draw the player’s attention and entice them to collect them.

The more I thought about it, the more the idea clicked. I sketched a new prototype.

La nouvelle version de l'icône

And there it was—a breakthrough! It wasn’t final yet, but the direction felt right:

  • The large magic wand made it instantly recognizable.
  • The bright, vibrant colors were eye-catching.
  • It had a fun, playful vibe.

Here’s how the design evolved:

Les différents essais de la version 2 On the first row, I experimented with star colors to make it more prominent. On the second row, I enhanced the shine and highlights for a more realistic effect.

And finally, the finished icon:

Version finale The gradient background, a variation of the app’s main pink color, adds depth and contrast while reinforcing the app’s theme.

I’m genuinely proud of this result! It took much longer than I expected, but taking the time to step back and rethink the design was absolutely worth it.

A Few Tips

  • Use subtle white gradients to add depth and shine, enhancing the icon’s realism.
  • Colored shadows create a glowing effect, adding a magical aura.
  • A background gradient not only adds depth but also ensures the main element stands out, maintaining the app’s visual identity.
]]>
https://snopia.net/en/blog/making-of-the-supertext-icon making-of-the-supertext-icon Thu, 19 Sep 2024 00:00:00 GMT
<![CDATA[Welcome]]> I’ve been developing apps, imagining concepts, designing user interfaces, and creating graphics for a long time. But I’ve always hesitated to share my work, mostly because I feel that what I put online is never perfect or fully polished. More so these days.

Over time, I’ve learned to release concepts faster by focusing solely on the core functionality of an app. This approach has been a game-changer for me. It allows me to iterate quickly on an idea, validate its concept, and assess its viability. I no longer spend years working on ideas that may never come to fruition. That said, it took me a while to accept launching something imperfect—and even longer to share it publicly.

But an app isn’t just the icon on your home screen. It’s an entire creative journey, with its own challenges, failures, and triumphs. It’s about inspiration, questioning, decision-making, compromises, research, experimentation, and thoughtful design.

With this blog, I want to offer something different. On the homepage, you’ll find the apps I’ve created. But I also want to share insights into creation, reflections, and even technical details.

Welcome to Snopia, a space for exploration and curiosity.

]]>
https://snopia.net/en/blog/welcome welcome Sun, 28 Jul 2024 00:00:00 GMT