tvOS UISearchController in SwiftUI

I have been struggling to get a functional UISearchController in a SwiftUI app, under a tab bar. I've scoured the web and documentation, and managed to get something almost there, however the keyboard on the search view does not respond to left and right controls. I've seen a post on StackExchange where someone has a similar issue, but no solution.

Can anyone help to show how to make a functional search view in a SwiftUI app on tvOS 13 ?

One thing to note, while working through the views, I discovered a bug in USearchView which doesn't set a horizontal constraint for the keyboard area in one of its' inner views. I include a fix for that which adds in the constraint if one can't be found.

I've attached the full source, just add to an empty tvOS SwiftUI project (and remove AppDelegate and ContentView)
//
//  AppDelegate.swift
//  TestSearch3
//
//  Created by Guy Brooker on 30/6/20.
//  Copyright © 2020 Guy Brooker. All rights reserved.
//

import UIKit
import SwiftUI

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?


    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView()

        // Use a UIHostingController as window root view controller.
        let window = UIWindow(frame: UIScreen.main.bounds)
        window.rootViewController = UIHostingController(rootView: contentView)
        self.window = window
        window.makeKeyAndVisible()
        return true
    }

    func applicationWillResignActive(_ application: UIApplication) {
        // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
        // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game.
    }

    func applicationDidEnterBackground(_ application: UIApplication) {
        // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
    }

    func applicationWillEnterForeground(_ application: UIApplication) {
        // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
    }

    func applicationDidBecomeActive(_ application: UIApplication) {
        // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
    }


 
}

// MARK:- ContentView

struct ContentView: View {
    @State private var selection = 0
 
    @ObservedObject var state = SearchState()

    var body: some View {
        TabView(selection: $selection){
            Text("First View").font(.title)
                .tabItem { Text("First") }
                .tag(0)
            
            Text("Second View").font(.title)
                .tabItem { Text("Second") }
                .tag(1)
            
            PageView(TestView(state: state), state: state)
                .tabItem { Text("Search") }
                .tag(2)
        }
    }
}

// MARK: - Search

class SearchState: NSObject, ObservableObject, UISearchResultsUpdating {
    @Published var text: String = ""
    
    func updateSearchResults(for searchController: UISearchController) {
        let searchBar = searchController.searchBar
        self.text = searchBar.text ?? ""
    }
}

struct PageView<Page: View>: View {
    var viewController: UIHostingController<Page>
    @ObservedObject var state: SearchState

    init(_ resultsView: Page, state: SearchState) {
        self.viewController = UIHostingController(rootView: resultsView)
        self.state = state
    }
    var body: some View {
        PageViewController(controller: viewController, state: state)
    }
}

struct PageViewController: UIViewControllerRepresentable {
    var controller: UIViewController
    @ObservedObject var state: SearchState

    func makeUIViewController(context: Context) -> UINavigationController {
        
        let searchController = UISearchController(searchResultsController: controller)
        searchController.searchResultsUpdater = state
        searchController.searchBar.placeholder = NSLocalizedString("Enter search (e.g. Shawshank)", comment: "")
        
        // Contain the `UISearchController` in a `UISearchContainerViewController`.
        let searchContainer = UISearchContainerViewController(searchController: searchController)
        searchContainer.title = NSLocalizedString("Search", comment: "")

        // Finally contain the `UISearchContainerViewController` in a `UINavigationController`.
        let searchNavigationController = UINavigationController(rootViewController: searchContainer)

        return searchNavigationController
    }
    
    func updateUIViewController(_ searchContainer: UINavigationController, context: Context) {
        print("Update Page Search Controller")
        
        // Add horizontal constraint uiKBfv.left = super.left - 90 to
        // searchContainer.children[0] as? UISearchContainerViewController)?.searchController.view.constraints
        
        if searchContainer.children.count > 0,
            let searchController = (searchContainer.children[0] as? UISearchContainerViewController)?.searchController,
            let searchControllerView = searchController.view,
            searchController.children.count > 1,
            let uiKBFocusView = searchController.children[1].view,
            !searchControllerView.constraints.reduce(false, {$0 || ($1.firstAttribute == NSLayoutConstraint.Attribute.centerX) }){
        
            let horizontalConstraint = NSLayoutConstraint(item: uiKBFocusView, attribute: NSLayoutConstraint.Attribute.centerX, relatedBy: NSLayoutConstraint.Relation.equal, toItem: searchControllerView, attribute: NSLayoutConstraint.Attribute.centerX, multiplier: 1, constant: 0)
            
            searchControllerView.addConstraints([horizontalConstraint])
        }
    }
}

// MARK:- Results view


struct PosterView: View {
    let item: String
    let width: CGFloat
    @State private var showingChildView = false

    var body: some View {
        Group {
            NavigationLink(destination:
                Button(action: {
                    self.showingChildView = false
                }, label: { Text("Back from \(item)") }),
                isActive: self.$showingChildView
            ) {
                VStack {
                    Image(systemName: "film")
                        .font(.largeTitle)
                    Text(item)
                }.frame(width: width, height: 400)
            }.frame(width: width+80, height: 440)
                .background(Color.yellow)
        }
    }
}

struct TestView: View {
    @ObservedObject var state: SearchState
    let list = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]
    
    var body: some View {
        let contentWidth: CGFloat = 200
        let buttonHPadding: CGFloat = 40*2
        let buttonWidth = (contentWidth + buttonHPadding)
        let horizontalSpacing: CGFloat = 40
        let count: CGFloat = CGFloat(list.count)
        let scrollWidth: CGFloat = max((buttonWidth * count) + (horizontalSpacing * (count + 2)), 1920.0) // Leave enough space for the shadows
        
        return ScrollView(.horizontal) {
            NavigationView {
                HStack(spacing: horizontalSpacing) {
                    if state.text == "" {
                        ForEach(list, id: \.self) { item in
                            PosterView(item: item, width: contentWidth)
                        }
                    } else {
                        ForEach(list.filter({$0.lowercased().contains(state.text.lowercased())}), id: \.self) { item in
                            PosterView(item: item, width: contentWidth)
                        }
                    }
                }.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .padding(EdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80))
            }
            .frame(minWidth: scrollWidth, maxWidth: .infinity, alignment: .leading)
        }.frame(width: 1740, height: 475, alignment: .leading)
    }
}



The UISearchView HostingView:
Code Block swift
struct PageView<Page: View>: View {
    var viewController: UIHostingController<Page>
    @ObservedObject var state: SearchState
    init(_ resultsView: Page, state: SearchState) {
        self.viewController = UIHostingController(rootView: resultsView)
        self.state = state
    }
    var body: some View {
        PageViewController(controller: viewController, state: state)
    }
}
struct PageViewController: UIViewControllerRepresentable {
    var controller: UIViewController
    @ObservedObject var state: SearchState
    func makeUIViewController(context: Context) -> UINavigationController {
        let searchController = UISearchController(searchResultsController: controller)
        searchController.searchResultsUpdater = state
        searchController.searchBar.placeholder = NSLocalizedString("Enter search (e.g. Shawshank)", comment: "")
        let searchContainer = UISearchContainerViewController(searchController: searchController)
        searchContainer.title = NSLocalizedString("Search", comment: "")
        let searchNavigationController = UINavigationController(rootViewController: searchContainer)
        return searchNavigationController
    }
    func updateUIViewController(_ searchContainer: UINavigationController, context: Context) {
        if searchContainer.children.count > 0,
            let searchController = (searchContainer.children[0] as? UISearchContainerViewController)?.searchController,
            let searchControllerView = searchController.view,
            searchController.children.count > 1,
            let uiKBFocusView = searchController.children[1].view,
            !searchControllerView.constraints.reduce(false, {$0 || ($1.firstAttribute == NSLayoutConstraint.Attribute.centerX) }){
            let horizontalConstraint = NSLayoutConstraint(item: uiKBFocusView, attribute: NSLayoutConstraint.Attribute.centerX, relatedBy: NSLayoutConstraint.Relation.equal, toItem: searchControllerView, attribute: NSLayoutConstraint.Attribute.centerX, multiplier: 1, constant: 0)
searchControllerView.addConstraints([horizontalConstraint])
        }
    }
}


The Search State and results view
Code Block swift
class SearchState: NSObject, ObservableObject, UISearchResultsUpdating {
    @Published var text: String = ""
    
    func updateSearchResults(for searchController: UISearchController) {
        let searchBar = searchController.searchBar
        self.text = searchBar.text ?? ""
    }
}



Hi did you find the solution? I'm facing the same issues using SwiftUI

what I found so far: On tvOS, start with a UISearchContainerViewController to manage the presentation of the search controller. See UIKit Catalog (tvOS): Creating and Customizing UIKit Controls to learn how to implement a search controller embedded inside a UISearchContainerViewController object. link: https://developer.apple.com/documentation/uikit/uisearchcontroller

so we should use UISearchContainerViewController instead of using SearchViewController

tvOS UISearchController in SwiftUI
 
 
Q