| 1 | // |
| 2 | // TVTabBarController.swift |
| 3 | // JomloCV tvOS Example |
| 4 | // |
| 5 | // Created by Seto Elkahfi on 2026-01-27. |
| 6 | // Copyright © 2026 Seto Elkahfi. All rights reserved. |
| 7 | // |
| 8 | |
| 9 | import UIKit |
| 10 | |
| 11 | /// A tab bar controller mimicking the Viaplay tvOS app navigation structure. |
| 12 | /// |
| 13 | /// The tvOS tab bar sits at the top of the screen and is revealed by swiping up |
| 14 | /// on the Siri Remote. Each tab represents a content category page, and focus |
| 15 | /// transitions from the tab bar to the selected view controller's content. |
| 16 | /// |
| 17 | /// This implementation includes the focus-handoff pattern from Viaplay's |
| 18 | /// `TabBarController` — when focus moves from the tab bar down to content, |
| 19 | /// we redirect focus to the selected view controller for smooth navigation. |
| 20 | @MainActor |
| 21 | final class TVTabBarController: UITabBarController { |
| 22 | |
| 23 | private var shouldGiveFocusToSelectedViewController = false |
| 24 | |
| 25 | override func viewDidLoad() { |
| 26 | super.viewDidLoad() |
| 27 | view.backgroundColor = UIColor(white: 0.05, alpha: 1.0) |
| 28 | setupTabs() |
| 29 | } |
| 30 | |
| 31 | // MARK: - Focus Engine |
| 32 | |
| 33 | /// Redirects focus to the selected view controller when transitioning |
| 34 | /// from the tab bar down to content — ensures the BlocksViewController |
| 35 | /// (or equivalent) gains proper focus control. |
| 36 | override var preferredFocusEnvironments: [UIFocusEnvironment] { |
| 37 | guard let selectedViewController, shouldGiveFocusToSelectedViewController else { |
| 38 | return super.preferredFocusEnvironments |
| 39 | } |
| 40 | shouldGiveFocusToSelectedViewController = false |
| 41 | return [selectedViewController] |
| 42 | } |
| 43 | |
| 44 | /// Intercepts focus transitions from the tab bar to content. |
| 45 | /// When focus moves away from the tab bar downward, we set a flag |
| 46 | /// and force a focus update so `preferredFocusEnvironments` can |
| 47 | /// hand focus to the selected view controller cleanly. |
| 48 | override func shouldUpdateFocus(in context: UIFocusUpdateContext) -> Bool { |
| 49 | if let nextFocusedView = context.nextFocusedView, |
| 50 | !nextFocusedView.isDescendant(of: tabBar), |
| 51 | let previouslyFocusedView = context.previouslyFocusedView, |
| 52 | previouslyFocusedView.isDescendant(of: tabBar) |
| 53 | { |
| 54 | shouldGiveFocusToSelectedViewController = true |
| 55 | setNeedsFocusUpdate() |
| 56 | updateFocusIfNeeded() |
| 57 | return false |
| 58 | } |
| 59 | |
| 60 | shouldGiveFocusToSelectedViewController = false |
| 61 | return true |
| 62 | } |
| 63 | |
| 64 | // MARK: - Tab Configuration |
| 65 | |
| 66 | private func setupTabs() { |
| 67 | let homeVC = TVStreamingHomeViewController() |
| 68 | homeVC.tabBarItem = UITabBarItem(title: "Home", image: nil, tag: 0) |
| 69 | homeVC.tabBarItem.accessibilityIdentifier = "Start" |
| 70 | let homeNav = UINavigationController(rootViewController: homeVC) |
| 71 | |
| 72 | let seriesVC = TVSeriesViewController() |
| 73 | seriesVC.tabBarItem = UITabBarItem(title: "Series", image: nil, tag: 1) |
| 74 | seriesVC.tabBarItem.accessibilityIdentifier = "Series" |
| 75 | let seriesNav = UINavigationController(rootViewController: seriesVC) |
| 76 | |
| 77 | let moviesVC = TVMoviesViewController() |
| 78 | moviesVC.tabBarItem = UITabBarItem(title: "Movies", image: nil, tag: 2) |
| 79 | moviesVC.tabBarItem.accessibilityIdentifier = "Movies" |
| 80 | let moviesNav = UINavigationController(rootViewController: moviesVC) |
| 81 | |
| 82 | let sportsVC = TVSportsViewController() |
| 83 | sportsVC.tabBarItem = UITabBarItem(title: "Sport", image: nil, tag: 3) |
| 84 | sportsVC.tabBarItem.accessibilityIdentifier = "Sports" |
| 85 | let sportsNav = UINavigationController(rootViewController: sportsVC) |
| 86 | |
| 87 | let kidsVC = TVKidsViewController() |
| 88 | kidsVC.tabBarItem = UITabBarItem(title: "Kids", image: nil, tag: 4) |
| 89 | kidsVC.tabBarItem.accessibilityIdentifier = "Kids" |
| 90 | let kidsNav = UINavigationController(rootViewController: kidsVC) |
| 91 | |
| 92 | let searchVC = TVSearchViewController() |
| 93 | searchVC.tabBarItem = UITabBarItem(title: "Search", image: nil, tag: 5) |
| 94 | searchVC.tabBarItem.accessibilityIdentifier = "Search" |
| 95 | let searchNav = UINavigationController(rootViewController: searchVC) |
| 96 | |
| 97 | // Copy tabBarItem from the root VC to the nav so the tab bar shows titles |
| 98 | homeNav.tabBarItem = homeVC.tabBarItem |
| 99 | seriesNav.tabBarItem = seriesVC.tabBarItem |
| 100 | moviesNav.tabBarItem = moviesVC.tabBarItem |
| 101 | sportsNav.tabBarItem = sportsVC.tabBarItem |
| 102 | kidsNav.tabBarItem = kidsVC.tabBarItem |
| 103 | searchNav.tabBarItem = searchVC.tabBarItem |
| 104 | |
| 105 | viewControllers = [homeNav, seriesNav, moviesNav, sportsNav, kidsNav, searchNav] |
| 106 | } |
| 107 | } |