diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 3d17280cb..adf9cb205 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -4396,7 +4396,7 @@ SWIFT_OBJC_BRIDGING_HEADER = "AltStore/AltStore-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -4432,7 +4432,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "AltStore/AltStore-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/AltStore/Browse/FeaturedViewController.swift b/AltStore/Browse/FeaturedViewController.swift index 7068eafbe..c9d6bc045 100644 --- a/AltStore/Browse/FeaturedViewController.swift +++ b/AltStore/Browse/FeaturedViewController.swift @@ -64,6 +64,7 @@ class FeaturedViewController: UICollectionViewController internal private(set) var searchController: RSTSearchController! private var searchBrowseViewController: BrowseViewController! + private var lastLayoutWidth: CGFloat = 0 private var updateFediverseInteractionsResult: Result? @@ -121,12 +122,26 @@ class FeaturedViewController: UICollectionViewController self.updateFediverseInteractionsIfNeeded() } - override func viewDidAppear(_ animated: Bool) + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + self.navigationController?.navigationBar.tintColor = .altPrimary } + + override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + + // Recompute the width-adaptive sections when the available width changes + // (rotation, sidebar collapse/expand) so columns don't stay stale until a + // tab switch. + if self.collectionView.bounds.width != self.lastLayoutWidth + { + self.lastLayoutWidth = self.collectionView.bounds.width + self.collectionView.collectionViewLayout.invalidateLayout() + } + } } private extension FeaturedViewController @@ -149,14 +164,17 @@ private extension FeaturedViewController case .recentlyUpdated: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight)) let item = NSCollectionLayoutItem(layoutSize: itemSize) - - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight * 2 + spacing)) + + // Cap the carousel column width so banners don't stretch edge-to-edge + // on iPad; multiple columns then peek in from the sides. + let columnWidth = min(layoutEnvironment.container.effectiveContentSize.width, 440) + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(columnWidth), heightDimension: .absolute(AppBannerView.standardHeight * 2 + spacing)) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item, item]) // 2 items per group group.interItemSpacing = .fixed(spacing) let layoutSection = NSCollectionLayoutSection(group: group) layoutSection.interGroupSpacing = spacing - layoutSection.orthogonalScrollingBehavior = .groupPagingCentered + layoutSection.orthogonalScrollingBehavior = .continuous layoutSection.contentInsets.bottom = interSectionSpacing layoutSection.boundarySupplementaryItems = [ NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading) @@ -164,13 +182,16 @@ private extension FeaturedViewController return layoutSection case .categories: - let itemWidth = (layoutEnvironment.container.effectiveContentSize.width - spacing) / 2 + // Adaptive number of columns by width (2 on iPhone / narrow, more on iPad). + let width = layoutEnvironment.container.effectiveContentSize.width + let columns = max(2, Int(width / 320)) + let itemWidth = (width - spacing * CGFloat(columns - 1)) / CGFloat(columns) let itemHeight = 90.0 let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemHeight)) let item = NSCollectionLayoutItem(layoutSize: itemSize) - + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(itemHeight)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item]) // 2 items per group + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: Array(repeating: item, count: columns)) group.interItemSpacing = .fixed(spacing) let layoutSection = NSCollectionLayoutSection(group: group) @@ -201,8 +222,11 @@ private extension FeaturedViewController let itemHeight: NSCollectionLayoutDimension = if #available(iOS 17, *) { .uniformAcrossSiblings(estimate: 350) } else { .estimated(350) } let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight) let item = NSCollectionLayoutItem(layoutSize: itemSize) - - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight) + + // Cap the featured card width so it doesn't stretch across an iPad; + // the paging carousel then shows the next card peeking in. + let cardWidth = min(layoutEnvironment.container.effectiveContentSize.width, 560) + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(cardWidth), heightDimension: itemHeight) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) group.interItemSpacing = .fixed(spacing) @@ -213,7 +237,7 @@ private extension FeaturedViewController let layoutSection = NSCollectionLayoutSection(group: group) layoutSection.interGroupSpacing = spacing - layoutSection.orthogonalScrollingBehavior = .groupPagingCentered + layoutSection.orthogonalScrollingBehavior = .continuous layoutSection.contentInsets.top = 8 layoutSection.contentInsets.bottom = interSectionSpacing layoutSection.boundarySupplementaryItems = [titleHeader, buttonHeader] diff --git a/AltStore/Components/HeaderContentViewController.swift b/AltStore/Components/HeaderContentViewController.swift index 8552c2705..05a472743 100644 --- a/AltStore/Components/HeaderContentViewController.swift +++ b/AltStore/Components/HeaderContentViewController.swift @@ -362,7 +362,12 @@ class HeaderContentViewController UICollectionViewLayout + { + let spacing = MyAppsViewController.gridSpacing + let sectionInsets = NSDirectionalEdgeInsets(top: 12, leading: 0, bottom: 20, trailing: 0) + + return UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in + guard let self else { return nil } + + let section = Section.allCases[sectionIndex] + let width = layoutEnvironment.container.effectiveContentSize.width + + switch section + { + case .noUpdates: + let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44)) + let item = NSCollectionLayoutItem(layoutSize: size) + let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item]) + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.contentInsetsReference = .safeArea + return layoutSection + + case .updates: + // Update cards self-size via Auto Layout, so use an estimated height. + let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(156)) + let item = NSCollectionLayoutItem(layoutSize: size) + let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item]) + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = spacing + layoutSection.contentInsets = sectionInsets + layoutSection.contentInsetsReference = .safeArea + if (self.updatesDataSource.fetchedResultsController.fetchedObjects?.count ?? 0) > Self.maximumCollapsedUpdatesCount + { + layoutSection.boundarySupplementaryItems = [ + NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(26)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) + ] + } + return layoutSection + + case .activeApps, .inactiveApps: + let columns = max(1, Int(width / 350)) + let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(88))) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(88)), subitem: item, count: columns) + group.interItemSpacing = .fixed(spacing) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = spacing + layoutSection.contentInsets = sectionInsets + layoutSection.contentInsetsReference = .safeArea + + var supplementaries: [NSCollectionLayoutBoundarySupplementaryItem] = [] + + // Header: active apps always show one; inactive apps only when populated + // (matches the old `referenceSizeForHeaderInSection`). + let showsHeader = (section == .activeApps) || self.inactiveAppsDataSource.itemCount > 0 + if showsHeader + { + supplementaries.append(NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(29)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)) + } + + // The App IDs footer sits under whichever section is last (active when + // there are no inactive apps, otherwise inactive) and only with a team. + let hasTeam = DatabaseManager.shared.activeTeam() != nil + let showsFooter: Bool + switch section + { + case .activeApps: showsFooter = hasTeam && self.inactiveAppsDataSource.itemCount == 0 + case .inactiveApps: showsFooter = hasTeam && self.inactiveAppsDataSource.itemCount > 0 + default: showsFooter = false + } + if showsFooter + { + supplementaries.append(NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(80)), elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)) + } + + layoutSection.boundarySupplementaryItems = supplementaries + return layoutSection + } + } + } +} + extension MyAppsViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] diff --git a/AltStore/News/NewsViewController.swift b/AltStore/News/NewsViewController.swift index 6c26b0783..8e85e67aa 100644 --- a/AltStore/News/NewsViewController.swift +++ b/AltStore/News/NewsViewController.swift @@ -28,12 +28,21 @@ private class AppBannerFooterView: UICollectionReusableView self.bannerView.translatesAutoresizingMaskIntoConstraints = false self.addSubview(self.bannerView) - + + // The footer spans the full width (flow-layout footers ignore the section's + // centering insets), so cap + center the banner to match the news cards. + // 680 = the news card's content width (720) minus the card's ~20pt layout + // margins on each side, so the banner lines up with the card's visible width. + let preferredWidthConstraint = self.bannerView.widthAnchor.constraint(equalToConstant: 680) + preferredWidthConstraint.priority = .defaultHigh + NSLayoutConstraint.activate([ self.bannerView.topAnchor.constraint(equalTo: self.topAnchor), self.bannerView.bottomAnchor.constraint(equalTo: self.bottomAnchor), - self.bannerView.leadingAnchor.constraint(equalTo: self.layoutMarginsGuide.leadingAnchor), - self.bannerView.trailingAnchor.constraint(equalTo: self.layoutMarginsGuide.trailingAnchor) + self.bannerView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + self.bannerView.leadingAnchor.constraint(greaterThanOrEqualTo: self.layoutMarginsGuide.leadingAnchor), + self.bannerView.trailingAnchor.constraint(lessThanOrEqualTo: self.layoutMarginsGuide.trailingAnchor), + preferredWidthConstraint, ]) } @@ -52,7 +61,14 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing private var retryButton: UIButton! private var prototypeCell: NewsCollectionViewCell! - + + /// On wide (iPad) layouts, constrain news cards to a readable column rather + /// than stretching them across the whole screen. iPhone widths are below this, + /// so the layout there is unchanged. + private let maximumContentWidth: CGFloat = 720 + + private var lastLayoutWidth: CGFloat = 0 + // Cache private var cachedCellSizes = [String: CGSize]() private var cancellables = Set() @@ -139,13 +155,21 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() - + if self.collectionView.contentInset.bottom != 20 { // Triggers collection view update in iOS 13, which crashes if we do it in viewDidLoad() // since the database might not be loaded yet. self.collectionView.contentInset.bottom = 20 } + + // Recompute the centered column when the available width changes (rotation, + // sidebar collapse/expand) so it doesn't keep the previous orientation's inset. + if self.collectionView.bounds.width != self.lastLayoutWidth + { + self.lastLayoutWidth = self.collectionView.bounds.width + self.collectionView.collectionViewLayout.invalidateLayout() + } } override func viewIsAppearing(_ animated: Bool) @@ -505,20 +529,26 @@ extension NewsViewController: UICollectionViewDelegateFlowLayout { let item = self.dataSource.item(at: indexPath) let globallyUniqueID = item.globallyUniqueID ?? item.identifier - - if let previousSize = self.cachedCellSizes[globallyUniqueID] + let width = self.contentWidth(in: collectionView) + + // Key the cache by width as well as item: the available width now changes + // on iPad (rotation, sidebar collapse/expand), and a size cached at one + // width must not be reused at another or the cell gets squeezed. + let cacheKey = "\(globallyUniqueID)|\(Int(width))" + + if let previousSize = self.cachedCellSizes[cacheKey] { return previousSize } - - let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width) + + let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: width) NSLayoutConstraint.activate([widthConstraint]) defer { NSLayoutConstraint.deactivate([widthConstraint]) } - + self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath) - + let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - self.cachedCellSizes[globallyUniqueID] = size + self.cachedCellSizes[cacheKey] = size return size } @@ -538,15 +568,24 @@ extension NewsViewController: UICollectionViewDelegateFlowLayout func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { - var insets = UIEdgeInsets(top: 30, left: 0, bottom: 13, right: 0) - + // Center the constrained column when the collection view is wider than it. + let horizontalInset = max(0, (collectionView.bounds.width - self.contentWidth(in: collectionView)) / 2) + var insets = UIEdgeInsets(top: 30, left: horizontalInset, bottom: 13, right: horizontalInset) + if section == 0 { insets.top = 10 } - + return insets } + + /// The width news cards are laid out at: the full width on iPhone, but capped + /// and centered on wider (iPad) layouts so cards don't stretch edge-to-edge. + private func contentWidth(in collectionView: UICollectionView) -> CGFloat + { + return min(collectionView.bounds.width, self.maximumContentWidth) + } } extension NewsViewController: UIViewControllerPreviewingDelegate diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift index f70e7d014..d24cd14b6 100644 --- a/AltStore/Sources/SourcesViewController.swift +++ b/AltStore/Sources/SourcesViewController.swift @@ -35,6 +35,7 @@ class SourcesViewController: UICollectionViewController private var placeholderViewCenterYConstraint: NSLayoutConstraint! private var _viewDidAppear = false + private var lastLayoutWidth: CGFloat = 0 private weak var _installingApp: StoreApp? override func viewDidLoad() @@ -63,13 +64,13 @@ class SourcesViewController: UICollectionViewController #else self.placeholderView.textLabel.text = NSLocalizedString("Add More Sources", comment: "") #endif - self.placeholderView.detailTextLabel.textAlignment = .natural + self.placeholderView.detailTextLabel.textAlignment = .center backgroundView.addSubview(self.placeholderView) let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title3).bolded() self.placeholderView.textLabel.font = UIFont(descriptor: fontDescriptor, size: 0.0) self.placeholderView.detailTextLabel.font = UIFont.preferredFont(forTextStyle: .body) - self.placeholderView.detailTextLabel.textAlignment = .natural + self.placeholderView.detailTextLabel.textAlignment = .center self.placeholderViewButton = UIButton(type: .system, primaryAction: UIAction(title: NSLocalizedString("View Recommended Sources", comment: "")) { [weak self] _ in self?.performSegue(withIdentifier: "addSource", sender: nil) @@ -123,10 +124,19 @@ class SourcesViewController: UICollectionViewController self.handleAddSourceDeepLink() } - override func viewDidLayoutSubviews() + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - + + // Recompute the adaptive grid when the available width changes (rotation, + // sidebar collapse/expand). Without this the layout keeps the previous + // orientation's column widths until you switch tabs. + if self.collectionView.bounds.width != self.lastLayoutWidth + { + self.lastLayoutWidth = self.collectionView.bounds.width + self.collectionView.collectionViewLayout.invalidateLayout() + } + // Vertically center placeholder view in gap below first item. let indexPath = IndexPath(item: 0, section: 0) @@ -146,51 +156,63 @@ private extension SourcesViewController { func makeLayout() -> UICollectionViewCompositionalLayout { - var configuration = UICollectionLayoutListConfiguration(appearance: .grouped) - configuration.showsSeparators = false - configuration.backgroundColor = .clear - - configuration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in - guard let self else { return UISwipeActionsConfiguration(actions: []) } - - let source = self.dataSource.item(at: indexPath) - var actions: [UIContextualAction] = [] - - if source.identifier != Source.altStoreIdentifier - { - // Prevent users from removing AltStore source. - - let removeAction = UIContextualAction(style: .destructive, - title: NSLocalizedString("Remove", comment: "")) { _, _, completion in - self.remove(source, completionHandler: completion) - } - removeAction.image = UIImage(systemName: "trash.fill") - - actions.append(removeAction) - } - - if let error = source.error - { - let viewErrorAction = UIContextualAction(style: .normal, - title: NSLocalizedString("View Error", comment: "")) { _, _, completion in - self.present(error) - completion(true) - } - viewErrorAction.backgroundColor = .systemYellow - viewErrorAction.image = UIImage(systemName: "exclamationmark.circle.fill") - - actions.append(viewErrorAction) - } - - let config = UISwipeActionsConfiguration(actions: actions) - config.performsFirstActionWithFullSwipe = false - - return config - } - - let layout = UICollectionViewCompositionalLayout.list(using: configuration) + // Lay sources out as an adaptive grid of banners (1 column on iPhone, more + // on iPad by width) instead of full-width list rows. Remove / View Error — + // previously swipe actions, which a grid doesn't support — move to the + // context menu in `contextMenuConfigurationForItemAt`. + let configuration = UICollectionViewCompositionalLayoutConfiguration() + configuration.contentInsetsReference = .safeArea + + let layout = UICollectionViewCompositionalLayout(sectionProvider: { _, layoutEnvironment in + let spacing = 10.0 + let width = layoutEnvironment.container.effectiveContentSize.width + // Width-based responsive columns (~350pt each): iPhone → 1, iPad portrait + // → 2, iPad landscape → 3. Pure width avoids the size-class trap where a + // tiled detail under ~768pt reports compact and collapses to 1 column. + let columns = max(1, Int(width / 350)) + // Absolute per-column width so `columns` items actually share a row. + // (A `fractionalWidth(1.0)` item is 100% of the group, i.e. one column.) + let itemWidth = (width - spacing * CGFloat(columns - 1)) / CGFloat(columns) + + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(AppBannerView.standardHeight)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: Array(repeating: item, count: columns)) + group.interItemSpacing = .fixed(spacing) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = spacing + // Edge-to-edge within the safe area (matching the My Apps grid); the + // `.safeArea` reference keeps the cards aligned with the title. + layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0) + return layoutSection + }, configuration: configuration) + return layout } + + func contextMenuActions(for source: Source) -> [UIAction] + { + var actions: [UIAction] = [] + + if let error = source.error + { + actions.append(UIAction(title: NSLocalizedString("View Error", comment: ""), image: UIImage(systemName: "exclamationmark.circle")) { [weak self] _ in + self?.present(error) + }) + } + + if source.identifier != Source.altStoreIdentifier + { + // Prevent users from removing AltStore source. + actions.append(UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in + self?.remove(source) + }) + } + + return actions + } func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { @@ -501,10 +523,21 @@ extension SourcesViewController override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { self.collectionView.deselectItem(at: indexPath, animated: true) - + let source = self.dataSource.item(at: indexPath) self.showSourceDetails(for: source) } + + override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? + { + let source = self.dataSource.item(at: indexPath) + let actions = self.contextMenuActions(for: source) + guard !actions.isEmpty else { return nil } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + UIMenu(children: actions) + } + } } extension SourcesViewController: NSFetchedResultsControllerDelegate diff --git a/AltStore/TabBarController.swift b/AltStore/TabBarController.swift index 1bbfd697f..bff41b3c3 100644 --- a/AltStore/TabBarController.swift +++ b/AltStore/TabBarController.swift @@ -20,6 +20,48 @@ extension TabBarController case browse case myApps case settings + + /// Stable identifier used to look up the matching `UITab` when the + /// iPad sidebar is active (the legacy `selectedIndex` no longer maps + /// 1:1 to the tabs once they're grouped into sections). + var identifier: String + { + switch self + { + case .news: return "news" + case .sources: return "sources" + case .browse: return "browse" + case .myApps: return "myApps" + case .settings: return "settings" + } + } + + /// Localized title shown in the iPad sidebar. + var sidebarTitle: String + { + switch self + { + case .news: return NSLocalizedString("News", comment: "") + case .sources: return NSLocalizedString("Sources", comment: "") + case .browse: return NSLocalizedString("Browse", comment: "") + case .myApps: return NSLocalizedString("My Apps", comment: "") + case .settings: return NSLocalizedString("Settings", comment: "") + } + } + + /// SF Symbol used for the sidebar/tab. Kept separate from the iPhone + /// tab bar artwork so the phone UI stays exactly as it is today. + var sidebarSymbolName: String + { + switch self + { + case .news: return "newspaper" + case .sources: return "books.vertical" + case .browse: return "bag" + case .myApps: return "square.stack.3d.up" + case .settings: return "gearshape" + } + } } } @@ -28,9 +70,13 @@ class TabBarController: UITabBarController private var initialSegue: (identifier: String, sender: Any?)? private var _viewDidAppear = false - + private var sourcesViewController: SourcesViewController! private var featuredViewController: FeaturedViewController! + + /// `true` once the iPad sidebar (iOS 18 `.tabSidebar`) has been configured, + /// at which point navigation goes through `selectedTab` instead of `selectedIndex`. + private var didConfigureSidebar = false required init?(coder aDecoder: NSCoder) { @@ -65,12 +111,18 @@ class TabBarController: UITabBarController self.view.insertSubview(hostingController.view, at: 0) hostingController.didMove(toParent: self) } + + // On iPad, present the tabs as a sidebar (iOS 18+). iPhone keeps the tab bar. + if #available(iOS 18, *) + { + self.configureSidebar() + } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + _viewDidAppear = true if let (identifier, sender) = self.initialSegue @@ -138,8 +190,8 @@ extension TabBarController self.loadViewIfNeeded() // Initialize sourcesViewController self.sourcesViewController?.deepLinkSourceURL = sourceURL } - - self.selectedIndex = Tab.sources.rawValue + + self.select(.sources) } } @@ -147,22 +199,22 @@ private extension TabBarController { @objc func openPatreonSettings(_ notification: Notification) { - self.selectedIndex = Tab.settings.rawValue + self.select(.settings) } - + @objc func importApp(_ notification: Notification) { - self.selectedIndex = Tab.myApps.rawValue + self.select(.myApps) } - + @objc func openErrorLog(_ notification: Notification) { - self.selectedIndex = Tab.settings.rawValue + self.select(.settings) } - + @objc func openBrowseTab(_ notification: Notification) { - self.selectedIndex = Tab.browse.rawValue + self.select(.browse) if let query = notification.userInfo?[AppDelegate.searchDeepLinkQueryKey] as? String { @@ -181,8 +233,8 @@ private extension TabBarController @objc func viewApp(_ notification: Notification) { - self.selectedIndex = Tab.browse.rawValue - + self.select(.browse) + if let presentedViewController = self.presentedViewController { presentedViewController.dismiss(animated: true) { @@ -193,8 +245,80 @@ private extension TabBarController } guard let storeApp = notification.userInfo?[AppDelegate.viewAppDeepLinkStoreAppKey] as? StoreApp else { return } - + let appViewController = AppViewController.makeAppViewController(app: storeApp) self.featuredViewController.navigationController?.pushViewController(appViewController, animated: true) } } + +private extension TabBarController +{ + /// Selects a tab whether we're showing the iPhone tab bar (index-based) or the + /// iPad sidebar (identity-based via `UITab`, which is robust to any future + /// reordering or grouping of the sidebar tabs). + private func select(_ tab: Tab) + { + if #available(iOS 18, *), self.didConfigureSidebar, let sidebarTab = self.sidebarTab(for: tab) + { + self.selectedTab = sidebarTab + } + else + { + self.selectedIndex = tab.rawValue + } + } +} + +@available(iOS 18, *) +private extension TabBarController +{ + /// Presents the tabs as an iPad sidebar (App Store / Music style) while keeping + /// the bottom tab bar in compact widths. iPhone never reaches here. + func configureSidebar() + { + // Use the device idiom rather than `traitCollection`: in `viewDidLoad` the + // view isn't in the window yet, so its trait collection can still report an + // `.unspecified` idiom. + guard UIDevice.current.userInterfaceIdiom == .pad else { return } + + // Capture the storyboard-instantiated navigation controllers *before* we + // replace `viewControllers` via `tabs`, so each `UITab` reuses the exact + // same instance. This preserves every deep link and the CoreData fetched + // results controllers already wired up inside them. We filter to navigation + // controllers because `viewControllers` may also contain the invisible + // `AppTrackerView` hosting controller added for marketplace install tracking. + let navigationControllers = (self.viewControllers ?? []).compactMap { $0 as? UINavigationController } + guard navigationControllers.count == Tab.allCases.count else { return } + + func makeTab(_ tab: Tab) -> UITab + { + let viewController = navigationControllers[tab.rawValue] + return UITab(title: tab.sidebarTitle, image: UIImage(systemName: tab.sidebarSymbolName), identifier: tab.identifier) { _ in + viewController + } + } + + // Keep the same order + initial selection as the iPhone tab bar so launch + // behavior is identical (the first tab is shown while sources refresh). + let newsTab = makeTab(.news) + self.tabs = [newsTab, makeTab(.sources), makeTab(.browse), makeTab(.myApps), makeTab(.settings)] + self.mode = .tabSidebar + self.selectedTab = newsTab + + // Show the sidebar and tile it beside the content (rather than overlapping + // it), so the detail column reports its own visible width. With overlap the + // content spans the full window *under* the sidebar, which pushes a grid's + // first column behind it. In portrait iOS collapses the tiled sidebar to a + // top strip (tap the toggle to reveal it); in landscape it stays alongside. + self.sidebar.isHidden = false + self.sidebar.preferredLayout = .tile + + self.didConfigureSidebar = true + } + + /// Finds the sidebar `UITab` that backs a given `Tab`. + private func sidebarTab(for tab: Tab) -> UITab? + { + return self.tabs.first { $0.identifier == tab.identifier } + } +}