9

I'm very new to Intents in Swift. Using the Dive Into App Intents video from WWDC 22 and the Booky example app, I've gotten my app to show up in the Shortcuts app and show an initial shortcut which opens the app to the main view. Here is the AppIntents code:

import AppIntents

enum NavigationType: String, AppEnum, CaseDisplayRepresentable {
    case folders
    case cards
    case favorites

    // This will be displayed as the title of the menu shown when picking from the options
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Navigation")
    
    static var caseDisplayRepresentations: [Self:DisplayRepresentation] = [
        .folders: DisplayRepresentation(title: "Folders"),
        .cards: DisplayRepresentation(title: "Card Gallery"),
        .favorites: DisplayRepresentation(title: "Favorites")
    ]
}

struct OpenCards: AppIntent {
    
    // Title of the action in the Shortcuts app
    static var title: LocalizedStringResource = "Open Card Gallery"
    // Description of the action in the Shortcuts app
    static var description: IntentDescription = IntentDescription("This action will open the Card gallery in the Hello There app.", categoryName: "Navigation")
    // This opens the host app when the action is run
    static var openAppWhenRun = true
    
    @Parameter(title: "Navigation")
    var navigation: NavigationType

    @MainActor // <-- include if the code needs to be run on the main thread
    func perform() async throws -> some IntentResult {
                ViewModel.shared.navigateToGallery()
            return .result()
    }
    
    static var parameterSummary: some ParameterSummary {
        Summary("Open \(\.$navigation)")
    }
    
}

And here is the ViewModel:

import SwiftUI

class ViewModel: ObservableObject {
    
    static let shared = ViewModel()
    
    @Published var path: any View = FavoritesView()
    
    // Clears the navigation stack and returns home
    func navigateToGallery() {
        path = FavoritesView()
    }
}

Right now, the Shortcut lets you select one of the enums (Folders, Cards, and Favorites), but always launches to the root of the app. Essentially no different then just telling Siri to open my app. My app uses a TabView in its ContentView with TabItems for the related Views:

            .tabItem {
                Text("Folders")
                Image(systemName: "folder.fill")
            }
            NavigationView {
                GalleryView()
            }
            .tabItem {
                Text("Cards")
                Image(systemName: "rectangle.portrait.on.rectangle.portrait.angled.fill")
            }
            NavigationView {
                FavoritesView()
            }
            .tabItem {
                Text("Favs")
                Image(systemName: "star.square.on.square.fill")
            }
            NavigationView {
                SettingsView()
            }
            .tabItem {
                Text("Settings")
                Image(systemName: "gear")
            }

How can I configure the AppIntents above to include something like "Open Favorites View" and have it launch into that TabItem view? I think the ViewModel needs tweaking... I've tried to configure it to open the FavoritesView() by default, but I'm lost on the proper path forward.

Thanks!

[EDIT -- updated with current code]

4 Answers 4

4

You're on the right track, you just need some way to do programmatic navigation.

With TabView, you can do that by passing a selection argument, a binding that you can then update to select a tab. An enum of all your tabs works nicely here. Here's an example view:

struct SelectableTabView: View {
    enum Tabs {
        case first, second
    }
    
    @State var selected = Tabs.first
    
    var body: some View {
        // selected will track the current tab:
        TabView(selection: $selected) {
            Text("First tab content")
                .tabItem {
                    Image(systemName: "1.circle.fill")
                }
                // The tag is how TabView knows which tab is which:
                .tag(Tabs.first)

            VStack {
                Text("Second tab content")
                
                Button("Select first tab") {
                    // You can change selected to navigate to a different tab:
                    selected = .first
                }
            }
            .tabItem {
                Image(systemName: "2.circle.fill")
            }
            .tag(Tabs.second)
        }
    }
}

So in your code, ViewModel.path could be an enum representing the available tabs, and you could pass a binding to path ($viewModel.path) to your TabView. Then you could simply set path = .favorites to navigate.

Sign up to request clarification or add additional context in comments.

13 Comments

Thank you so much! I always get Bindings backward. Does the @Binding go in ContentView or the ViewModel? If I put it in the ViewModel I get "No exact matches in call to initializer"
You should have a reference to your view model as an @ObservedObject in your view. Then you can create bindings out of its @Published properties by doing something like this: $model.path
Ok, I've got my enum enum Page { case folders, cards, favs } And I've got my ViewModel: class ViewModel: ObservableObject { //@State var selected = Page.folders static let shared = ViewModel() @Published var path: Page = .folders // configure nav func navigateToFolders() { path = .folders } func navigateToFavs() { path = .favs } func navigateToCards() { path = .cards } } And in ContentView: (at)ObservedObject var viewModel = ViewModel() and also TabView(selection: $viewModel.path)
Sounds like you're very close. If I were in your shoes, next thing I'd do would be to run the app with the debugger attached (i.e. just Product > Run in Xcode) and verify two things. First, does the programmatic nav work? Make a temporary button in the app that changes path, or set a breakpoint and just change it in the debugger (e ViewModel.shared.path = .favs or similar). Once that works, is your intent doing the right thing? Set a breakpoint in the func that changes path, make your intent run and check that it hits the breakpoint. If that all happens... it should work?
I read through your pastes... looks like your problem might be different instances of your view model. If the intent uses ViewModel.shared, and the view uses ViewModel() (a different instance), that's not going to work. You need to change the state of the same instance the View is observing.
|
3

From Apple's Accelerating app interactions with App Intents sample code, the right way to do this appears to be through the use of dependencies, namele the @Dependency property wrapper and the AppDependencyManager class:

The implementation for NavigationModel looks like this:

/// An observable object that manages the selection events for `NavigationSplitView`.
@MainActor
@Observable class NavigationModel {

    /// The selected item in `SidebarColumn`.
    var selectedCollection: TrailCollection?
    
    /// The selected item in the `NavigationSplitView` content view.
    var selectedTrail: Trail?
    
    /// The column visibility in the `NavigationSplitView`.
    var columnVisibility: NavigationSplitViewVisibility
    
    /// The visibility of a sheet when an activity is active.
    var displayInProgressActivityInfo = false
    
    init(selectedCollection: TrailCollection? = nil, columnVisibility: NavigationSplitViewVisibility = .all) {
        self.selectedCollection = selectedCollection
        self.columnVisibility = columnVisibility
    }
}

The navigation model uses TrailCollection objects. I won't put their implementation here because it's not very relevant and I don't want to have an answer with so much code.

Then you register the dependency in your App's init() method:

struct AppIntentsSampleApp: App {
    init() {
        // ...
        let trailDataManager = TrailDataManager.shared
        trailManager = trailDataManager
        
        let navigationModel = NavigationModel(selectedCollection: trailDataManager.nearMeCollection)
        sceneNavigationModel = navigationModel
        // ...
        AppDependencyManager.shared.add(dependency: navigationModel)
        // ,,,
    }
}

The sample app provides a OpenFavorites intent. This is where you register your dependency:

struct OpenFavorites: AppIntent {
    // ...
    @Dependency
    private var navigationModel: NavigationModel
    // ...
    // We also need the trailManager
    @Dependency
    private var trailManager: TrailDataManager
}

And then you can simply mutate this navigation model:

struct OpenFavorites: AppIntent {
    @MainActor
    func perform() async throws -> some IntentResult {
        navigationModel.selectedCollection = trailManager.favoritesCollection
    return .result()
    }
}

So based on your NavigationType object, you have the right idea. You just need to turn it into a dependency with the aforementioned APIs and you will have this done (possibly) the Apple way.

I have my reservations about this approach (what if you have a multi-window app where each Window keeps track of its own navigation hierarchy?), but it appears to be the best approach so far.

3 Comments

Unfortunately this breaks when you use scenes because it means every scene has the same navigation. The way to fix it is to remove the navigation object, make the intent return an OpenURLIntent and then use onOpenURL to handle changing the navigation @State within the View. It seems @Dependency is only for model access not for view state.
@malhal I was just about to reply on Mastodon haha, but yes, I haven't touched this in a while but ultimately the method I was using just relied on whatever scene became active after the action ran. But I really like this OpenURLIntent solution and I'd dare to say it should be the selected answer.
You can verify the Apple example app issue by opening two windows in split view and see that the navigation is shared between both and they can't work independently.
2

You need to use onOpenURL to change the selected tab, e.g.

.onOpenURL { url in 
    // check if the url is for the gallery
    selected = Tabs.gallery
}

Then you need an intent that hands a URL back to the Shortcuts app for it to open, e.g.

struct OpenGallery: AppIntent {
    
    static var title: LocalizedStringResource = "Open Gallery"

    static var description = IntentDescription("Opens the app and goes to the gallery.")
    
    
    @MainActor
    func perform() async throws -> some OpensIntent {
        return .result(opensIntent: OpenURLIntent(URL(string: "myapp:gallery")!))
    }
}

Here is some help with the URL scheme: https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app

Comments

2

Here is how you open your app deeplink/universal link from an Widget AppIntent on iOS 17 and newer:

WidgetIntent.swift

import AppIntents
import SwiftUI

struct WidgetIntent: AppIntent {
    static var title: LocalizedStringResource = "Dummy Title" // required
    static var description: IntentDescription = IntentDescription("Dummy Description", categoryName: "Navigation") // optional
    static var openAppWhenRun: Bool = true // required
    static var isDiscoverable: Bool = false // optional, if you want to hide this from the Shortcuts app, Spotlight, etc
    
    @Parameter(title: "Screen ID") // optional
    var screenId: Int // optional, only works when @Parameter exists
    
    init() {}
    
    // required only if you added a parameter before
    init(screenId: Int) {
        self.screenId = episodeId
    }
    
    func perform() async throws -> some IntentResult {
        let deepLinkURL = URL(string: "myApp://?screenId=\(screenId)")!
        await EnvironmentValues().openURL(deepLinkURL)
        return .result()
    }
}

⚠️ It’s required to have WidgetIntent as a member in both the app's target and the widget target.

enter image description here

Of course, make sure you added the myApp URL Type (URL Scheme) in Project Info.plist. Here is how.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.