Reading Time: 10 minutes
Welcome to the final chapter of our blog series, A Practical Guide to visionOS Development Using TCA. Whether you’ve been following along from the beginning or just joining us, I’m happy to have you here. If you’re new, I highly recommend starting with the earlier posts:
Part 1: A Practical Guide to visionOS Development Using TCA
Part 2: Handling Immersive Spaces
Part 3: Handling Multiple Windows
In this final blog, we’ll explore how to create a space portal and integrate it with the concepts covered in the previous posts. Additionally, we’ll add a planet information section to our visionOS application. As before, this tutorial builds on the provided starter projects and assumes you’re familiar with setting up a visionOS project, integrating Swift packages, and working with basic SwiftUI elements. If not, take a moment to review the earlier blogs before diving in.
Getting Started
Before we begin, you’ll need to download the Package folder from the link provided. This folder contains all the necessary 3D files for displaying planets in our application. Before starting, make sure to complete the following steps:
1. Download the Package folder and place it at the project level. (Download Link)
2. Launch your Xcode project.
3. If the Package folder doesn’t appear in the file hierarchy in Xcode, drag and drop it manually into the project hierarchy.
Build and run the project to ensure everything is linked and set up correctly. With that done, you’re ready to begin this tutorial.
![1*d4b_rdwx1ZEqCnLPHI0kNQ Zooming in on earth with the visionOS gif](https://egeniq.com/wp-content/uploads/2025/01/1d4b_rdwx1ZEqCnLPHI0kNQ.gif)
NOTE! In this section, we’re walking through each step of the HomeFeature reducer logic individually. Once we’ve completed the entire reducer setup, I’ll share the complete code for the HomeFeature reducer to provide a clear overview.
TODO: #1
Open the HomeFeature.swift file and replace TODO #1 with the following code snippet. I’ll walk you through each of the added properties shortly.
static func == (lhs: HomeStore.State, rhs: HomeStore.State) -> Bool
This method ensures the State struct properly conforms to the Equatable protocol by comparing the equality of its properties.
var space: Entity = .init()
The space variable serves as the root container for our virtual environment. It allows us to organize and manage all the components that will be part of the scene. Later, we’ll use it to add the star field and Earth 3D model, ensuring they are properly positioned, scaled, and rendered together.
var portal: Entity = .init()
The portal variable represents a gateway to the space, which will later display a 3D model of Earth against a star field background. Both the portal and space entities will be added to a RealityView to render the portal on the home window of our visionOS application, providing a visual representation of the space.
var sceneIsLoaded: Bool = false
The sceneIsLoaded variable is a boolean that tracks whether the 3D models (e.g., the Earth 3D model, star field, and portal) are ready to be displayed. Its initial value is set to false, indicating that the 3D scene is not yet ready. Once the scene is fully loaded, this property will be toggled to true, signaling the HomeView that the state has changed, prompting the view to re-render and display the updated scene.
TODO: #2
Next, we will add the following action cases to the Action enum. From the HomeView, we will dispatch the .startScene action case using .task { }. This ensures the action runs in an asynchronous context, allowing any long-running tasks (such as setting up 3D models or loading resources) to execute without blocking the main thread. Once dispatched, the .startScene action is sent to the HomeFeature reducer through the HomeView’s store. Once the space is set up, it will run the .createPortal action, passing in the space object. This process completes the scene setup by creating the portal entity and toggling the sceneIsLoaded property to true, prompting the view to re-render and display the updated scene.
For TODO: #3, the Xcode editor will prompt us with the error: “Switch must be exhaustive.” This happens because the switch statement must handle all possible cases of the Action enumeration. Let’s address this by adding the three new action cases we just added to our Action enum. This ensures the switch statement is exhaustive and prevents compiler errors.
We’ll start by adding logic to the .startScene action, which serves as the sole entry point for interactions between the HomeView and the HomeFeature reducer. This action initializes and configures the 3D environment for the virtual space. Here’s what we’re doing:
1. Initialize the Space Entity:
• Create a root Entity called space and attach a WorldComponent to define its role in the 3D environment.
2. Create a Starfield:
• Generate a large spherical MeshResource to represent the starfield (skybox).
• Use an UnlitMaterial to display a starfield texture.
• Load the texture resource (stars) and apply it to the material. Handle potential errors during the texture-loading process using a do catch block.
3. Add the Starfield Entity to Space:
• Create a new Entity for the starfield and set its ModelComponent using the generated mesh and material.
• Scale and orient the starfield for proper placement in the scene.
4. Load and Configure the Earth Model:
• Use ModelEntity.load to load a prebuilt Earth model from the realityKitContentBundle.
• Adjust its position and scale for correct rendering within the virtual space.
5. Assemble the Scene:
• Add the starField and Earth model as child entities to the space entity.
6. Send the Updated Space:
• Return an effect from the reducer to trigger the .createPortal(space) action with the configured space entity. This prepares the scene and updates the application state. We’ll be setting up the logic for this .createPortal action next.
Next, let’s handle the .createPortal case. This step focuses on creating and configuring the portal entity and updating the state to finalize the scene setup. Here’s what we’ll do:
1. Use the case let Pattern to Catch the Space Entity:
• First, we’ll use the case let pattern to extract the space entity that was sent from the .startScene action. This ensures we’re working with the same space entity initialized in the previous step.
2. Create the Portal Entity:
• Initialize a new Entity called portal that will act as a gateway to the space.
3. Add a Model Component to the Portal:
• Use the ModelComponent to give the portal a visual appearance. In this case, we define it as a circle and apply a custom material (PortalMaterial).
4. Set Up the Portal Component:
• Attach a PortalComponent to the portal entity, linking it to the space. This effectively connects the portal to the 3D environment it represents.
5. Update the Reducer’s State:
• Assign the space entity (created in the previous step) and the portal entity (just created) to the state.space and state.portal properties, respectively.
• These properties reside within the reducer’s State and act as the source of truth for RealityView, which we’ll add shortly to the HomeView, can access these entities to render the virtual environment correctly.
6. Mark the Scene as Loaded:
• Set state.sceneIsLoaded to true, indicating that the portal has been created, and the scene is now ready for use.
7. Return None:
• Since no further actions or effects are needed at this point, return .none to conclude this step.
These steps will finalize the portal setup and prepare the state for rendering the 3D environment in the RealityView.
Great work! We’ve completed setting up the HomeFeature reducer, here’s the final code for the entire reducer in the HomeFeature.swift file:
TODO: #4
Next, it’s time to see what we just did in action! Before that, we need to set up some functionality in the HomeView. Let’s head over to the HomeView.swift file and replace TODO: #4 with the following code snippet:
From the store (i.e., the HomeFeature reducer), we check the state of sceneIsLoaded. Once sceneIsLoaded is set to true within the .createPortal case, the reducer notifies the HomeView of the state change. The HomeView then renders the 3D content we set up using a RealityView.
HomeViewTextStack is a simple SwiftUI view that displays some textual information about our home planet alongside the 3D portal.
Lastly, to connect all the dots, we need to head to TODO: #5 and call our store to send the .startScene action to the HomeFeature reducer. This will trigger everything we’ve just set up to come together and work as intended. 😄
Great work! We’ve completed setting up the HomeView , here’s the final code for the entire reducer in the HomeView.swift file:
Let’s build and run the project for the very first time to see how everything comes together.
![1*Kh8E_XFVFXByJYoBhNRVEg](https://egeniq.com/wp-content/uploads/2025/01/1Kh8E_XFVFXByJYoBhNRVEg.webp)
Congratulations on the great job! 🎉🚀
If you were here to learn how to create a 3D portal in visionOS, this concludes that part of the blog. The remainder of the blog is optional to follow, as we will slightly revisit the steps from our third blog on visionOS, specifically handling multiple windows.
Great to have you here for the remainder of this blog! In this part, we’ll prepare a list to display cards or tiles for each planet and the Sun. Each tile will feature a label with the planet’s name, a 3D model of the planet, a brief description, and a “More about …” button. For TODO: #6 and #7, we’ll create the planet information tile. Replace those TODOs with the following code snippets.
![1*49yPfrrLZ7uJnRNKvGkeZA](https://egeniq.com/wp-content/uploads/2025/01/149yPfrrLZ7uJnRNKvGkeZA.webp)
Replace TODO: #6
Replace TODO: #7
The code snippets are largely self-explanatory, and by now, you should already have a solid understanding of what each line does. However, here is brief explanation.
First we sets up a tile with two key components:
1. planet: PlanetModel
• Holds details about the planet, such as its name, description, and 3D model.
2. action: @Sendable () -> Void
• A closure triggered by user interactions, such as tapping a button.
init(_ planet: PlanetModel, action: @Sendable @escaping () -> Void)
• Accepts a PlanetModel and an action closure.
• Assigns them to properties for use in the tile’s UI and behavior.
Then we simply set up the view to achieve the following goals:
• VStack Layout: Organizes the content vertically.
• Planet Name: Displays the planet’s name in large text.
• 3D Model: Renders the planet’s 3D model or shows a loading indicator if it’s loading.
• Description: Displays a brief description with centered alignment.
• Button: Allows the user to learn more about the planet by triggering the action.
• Styling: Adds padding, a fixed frame, rounded corners, and a glass-like background effect to the tile.
TODO: #8
It’s time to prepare the CollectionFeature reducer. This will allow us to manage the data and state for the CollectionView and feed it to the CollectionTile to display planet information. Let’s head to TODO: #8 inside the CollectionFeature.swift file.
Author Disclaimer: As I’ve already explained the steps for the remainder of this tutorial in detail in the previous blog (Part 3 Link), I’ll avoid re-explaining most of the concepts to prevent redundancy. However, if there’s a need to elaborate on specific points, I won’t hesitate to do so.
Replace TODO #8 with the following code
Replace TODO #9 with the following code
For TODO #10 Place the following code inside the switch action of Reducer.
Here’s the final code for the entire reducer in the CollectionFeature.swift file:
Great job! Now that the CollectionFeature reducer is fully set up, it’s time to move on to configuring the CollectionView. I’ve already included some boilerplate code that’s not relevant to this tutorial. Head to TODO: #11 and replace it with the following code snippet.
Here, we render a CollectionTile for the given planet and, depending on whether the planet’s type is already in store.openWindows, we either send the .closeWindow action with the planet type and a dismissal handler or send the .openWindow action with the planet type and an open window handler to the CollectionFeature Reducer.
Let’s build and run the app to see how the tiles look!
![1*HyO-_qgYgsZmPmLOUkA6Ow](https://egeniq.com/wp-content/uploads/2025/01/1HyO-_qgYgsZmPmLOUkA6Ow.gif)
Great job! As you may have noticed, the “More about …” buttons are not functional yet. Resolving this will be the focus of the final section of this blog.
TODO: #12
Let’s head to TODO: #12 inside the PlanetsFeature.swift file and replace it with the following code snippet.
Let’s do the same for TODO: #13 and TODO: #14 — head to these sections and replace them with the following code snippets.
Here’s the final code for the entire reducer in the PlanetsFeature.swift file:
For TODO: #15, place the following code inside the HStack.
Replace TODO: #16 with the following code:
Let’s recap what we just did. Since everything was a repetition of steps from previous blogs or concepts we’ve already covered in this blog, I’ll skip explaining the code snippets line by line and focus on the broader context.
We’ve just laid down the structure for displaying information about any planet a user selects to read more about through the collection view we created earlier. However, we’re not quite there yet because we still need to define a specific WindowGroup for each individual planet.
That’s exactly what we’ll do next by replacing TODO: #17 inside PlanetsApp.swift file with the following code snippet.
Let’s build and run the app for the last time.
Congratulations! By following and completing this series, you’ve gained a solid understanding of developing apps using The Composable Architecture framework. You now have a grasp of the fundamental concepts for visionOS app development, including handling immersive spaces, managing multiple windows, and displaying 3D content using RealityView.
Thank you so much for joining me on this amazing journey. I truly enjoyed designing these apps and did my best to explain the many topics we covered throughout this series as clearly as possible.
![merk-nederlandse-loterij nederlandse loterij en egeniq](https://egeniq.com/wp-content/uploads/2022/08/merk-nederlandse-loterij.jpg)
![merk-pathe-thuis pathe thuis en egeniq](https://egeniq.com/wp-content/uploads/2022/08/merk-pathe-thuis.jpg)
![merk-rpo rpo and egeniq](https://egeniq.com/wp-content/uploads/2022/08/merk-rpo.jpg)
![merk-mvw mvw en egeniq](https://egeniq.com/wp-content/uploads/2022/08/merk-mvw.jpg)
![merk-rtl rtl and egeniq](https://egeniq.com/wp-content/uploads/2022/08/merk-rtl.jpg)