From 201cd9537560272fc73916499fa8d087d118d2df Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:28:50 +1100 Subject: [PATCH 01/11] New feature: Immersion Mode - Toggle to enable while in postspageview via Page Settings or regular Settings screen - While scrolling, top and bottom bars will scroll away. Top bars will scroll up, bottom bars will scroll down. - When reaching the bottom of the page, bars will scroll back into position --- .../ImmersiveModeViewController.swift | 466 ++++++++++++++++++ App/Navigation/NavigationController.swift | 350 ++++++++++++- App/View Controllers/Posts/GradientView.swift | 58 +++ .../PostsPageSettingsViewController.swift | 74 ++- .../Posts/PostsPageTopBar.swift | 2 +- .../Posts/PostsPageTopBarLiquidGlass.swift | 184 +++++++ .../Posts/PostsPageTopBarProtocol.swift | 14 + .../Posts/PostsPageView.swift | 246 +++++++-- .../Posts/PostsPageViewController.swift | 333 +++++++++++-- App/Views/LiquidGlassTitleView.swift | 115 +++++ App/Views/PageNumberView.swift | 137 +++++ App/Views/RenderView.swift | 22 + Awful.xcodeproj/project.pbxproj | 26 +- .../Sources/AwfulSettings/Settings.swift | 3 + .../AwfulSettingsUI/Localizable.xcstrings | 3 + .../AwfulSettingsUI/SettingsView.swift | 2 + 16 files changed, 1944 insertions(+), 91 deletions(-) create mode 100644 App/Navigation/ImmersiveModeViewController.swift create mode 100644 App/View Controllers/Posts/GradientView.swift create mode 100644 App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift create mode 100644 App/View Controllers/Posts/PostsPageTopBarProtocol.swift create mode 100644 App/Views/LiquidGlassTitleView.swift create mode 100644 App/Views/PageNumberView.swift diff --git a/App/Navigation/ImmersiveModeViewController.swift b/App/Navigation/ImmersiveModeViewController.swift new file mode 100644 index 000000000..515fba735 --- /dev/null +++ b/App/Navigation/ImmersiveModeViewController.swift @@ -0,0 +1,466 @@ +// ImmersiveModeViewController.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Foundation +import UIKit +import AwfulSettings + +/// Protocol for view controllers that support immersion mode +/// This allows NavigationController to properly handle transitions +@MainActor +protocol ImmersiveModeViewController { + /// Called when the view controller should exit immersion mode + /// This is typically called when transitioning away from the view controller + func exitImmersionMode() +} + +// MARK: - ImmersionModeManager + +/// Manages immersion mode behavior for posts view +/// Handles hiding/showing navigation bars and toolbars with scroll gestures +final class ImmersionModeManager: NSObject { + + // MARK: - Dependencies + + /// The posts view that contains the bars to be transformed + weak var postsView: PostsPageView? + + /// The navigation controller for accessing the navigation bar + weak var navigationController: UINavigationController? + + /// The render view containing the scroll view + weak var renderView: RenderView? + + /// The toolbar to be transformed + weak var toolbar: UIToolbar? + + /// The top bar container to be transformed + weak var topBarContainer: UIView? + + // MARK: - Configuration + + /// Whether immersion mode is enabled in settings + @FoilDefaultStorage(Settings.immersionModeEnabled) private var immersionModeEnabled { + didSet { + if immersionModeEnabled && !oldValue { + // Just enabled - may need to update layout + postsView?.setNeedsLayout() + postsView?.layoutIfNeeded() + } else if !immersionModeEnabled && oldValue { + // Just disabled - reset everything + immersionProgress = 0.0 + resetAllTransforms() + safeAreaGradientView.alpha = 0.0 + postsView?.setNeedsLayout() + } + } + } + + // MARK: - State Properties + + /// Progress of immersion mode (0.0 = bars fully visible, 1.0 = bars fully hidden) + private var immersionProgress: CGFloat = 0.0 { + didSet { + guard immersionModeEnabled && !UIAccessibility.isVoiceOverRunning else { + immersionProgress = 0.0 + return + } + let oldProgress = oldValue + immersionProgress = immersionProgress.clamp(0...1) + if oldProgress != immersionProgress { + updateBarsForImmersionProgress() + } + } + } + + /// Last scroll offset to calculate delta + private var lastScrollOffset: CGFloat = 0 + + /// Cached navigation bar reference for performance + private weak var cachedNavigationBar: UINavigationBar? + + /// Flag to prevent recursive updates + private var isUpdatingBars = false + + // MARK: - UI Elements + + lazy var safeAreaGradientView: GradientView = { + let view = GradientView() + view.isUserInteractionEnabled = false + view.alpha = 0.0 + return view + }() + + // MARK: - Computed Properties + + /// Actual distance bars travel when hiding (calculated dynamically based on bar heights) + private var totalBarTravelDistance: CGFloat { + guard let postsView = postsView, + let window = postsView.window else { return 100 } + + let toolbarHeight = toolbar?.bounds.height ?? 44 + let deviceSafeAreaBottom = window.safeAreaInsets.bottom + let bottomDistance = toolbarHeight + deviceSafeAreaBottom + + if let navBar = findNavigationBar() { + let navBarHeight = navBar.bounds.height + let deviceSafeAreaTop = window.safeAreaInsets.top + let topDistance = navBarHeight + deviceSafeAreaTop + 30 + return max(bottomDistance, topDistance) + } + + return bottomDistance + } + + /// Check if content is scrollable enough to warrant immersion mode + private var isContentScrollableEnoughForImmersion: Bool { + guard let scrollView = renderView?.scrollView else { return false } + let scrollableHeight = scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom + return scrollableHeight > (totalBarTravelDistance * 2) + } + + // MARK: - Initialization + + override init() { + super.init() + } + + // MARK: - Configuration + + /// Configure the manager with required view references + func configure( + postsView: PostsPageView, + navigationController: UINavigationController?, + renderView: RenderView, + toolbar: UIToolbar, + topBarContainer: UIView + ) { + self.postsView = postsView + self.navigationController = navigationController + self.renderView = renderView + self.toolbar = toolbar + self.topBarContainer = topBarContainer + + // Clear cached navigation bar when configuration changes + cachedNavigationBar = nil + } + + // MARK: - Public Methods + + /// Force exit immersion mode (useful for scroll-to-top/bottom actions) + func exitImmersionMode() { + guard immersionModeEnabled && immersionProgress > 0 else { return } + immersionProgress = 0.0 + + // Explicitly reset navigation bar transform when exiting immersion mode + // This ensures the navigation bar is visible when returning to previous view + if let navBar = findNavigationBar() { + navBar.transform = .identity + } + } + + /// Check if immersion mode should affect scroll insets + func shouldAdjustScrollInsets() -> Bool { + return immersionModeEnabled + } + + /// Calculate bottom inset adjustment for immersion mode + func calculateBottomInset(normalBottomInset: CGFloat) -> CGFloat { + guard immersionModeEnabled, + let toolbar = toolbar, + let postsView = postsView else { + return normalBottomInset + } + + // During immersion mode, use the static toolbar position (without transforms) + // to keep contentInset constant and prevent scroll interference + let toolbarHeight = toolbar.sizeThatFits(postsView.bounds.size).height + let staticToolbarY = postsView.bounds.maxY - postsView.layoutMargins.bottom - toolbarHeight + return max(postsView.layoutMargins.bottom, postsView.bounds.maxY - staticToolbarY) + } + + /// Update layout for gradient view + func updateGradientLayout(in containerView: UIView) { + guard #available(iOS 26.0, *) else { return } + + let gradientHeight: CGFloat = containerView.window?.safeAreaInsets.top ?? containerView.safeAreaInsets.top + safeAreaGradientView.frame = CGRect( + x: containerView.bounds.minX, + y: containerView.bounds.minY, + width: containerView.bounds.width, + height: gradientHeight + ) + } + + /// Apply immersion transforms after layout if needed + func reapplyTransformsAfterLayout() { + if immersionModeEnabled && immersionProgress > 0 { + updateBarsForImmersionProgress() + } + } + + /// Determine if top bar should be positioned for immersion mode + func shouldPositionTopBarForImmersion() -> Bool { + return immersionModeEnabled + } + + /// Calculate top bar Y position for immersion mode + func calculateTopBarY(normalY: CGFloat) -> CGFloat { + guard immersionModeEnabled else { return normalY } + + // In immersion mode, position it to attach directly to the bottom edge of the navigation bar + if let navBar = findNavigationBar() { + // Position directly at the bottom edge of the nav bar (no gap) + return navBar.frame.maxY + } else { + // Fallback to estimated position + return postsView?.bounds.minY ?? 0 + (postsView?.layoutMargins.top ?? 0) + 44 + } + } + + // MARK: - Scroll View Delegate Methods + + /// Handle scroll view content size changes + func handleScrollViewDidChangeContentSize(_ scrollView: UIScrollView) { + // Check if content is still scrollable enough for immersion mode + if immersionModeEnabled && !isContentScrollableEnoughForImmersion { + // Reset bars to visible if content becomes too short + immersionProgress = 0 + } + } + + /// Handle scroll view will begin dragging + func handleScrollViewWillBeginDragging(_ scrollView: UIScrollView) { + lastScrollOffset = scrollView.contentOffset.y + + // On first drag, ensure bars start visible if at top + if immersionModeEnabled && scrollView.contentOffset.y < 20 { + immersionProgress = 0 + } + } + + /// Handle scroll view will end dragging + func handleScrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer, + isRefreshControlArmedOrTriggered: Bool + ) { + // Optional: Snap to complete if very close to fully shown/hidden + if immersionModeEnabled && !isRefreshControlArmedOrTriggered { + if immersionProgress > 0.9 { + // Snap to fully hidden + immersionProgress = 1.0 + } else if immersionProgress < 0.1 { + // Snap to fully visible + immersionProgress = 0.0 + } + // Otherwise leave at current position + } + } + + /// Handle scroll view did end dragging + func handleScrollViewDidEndDragging( + _ scrollView: UIScrollView, + willDecelerate: Bool, + isRefreshControlArmedOrTriggered: Bool + ) { + guard !willDecelerate else { return } + + // Handle immersion mode completion when drag ends + if immersionModeEnabled && !isRefreshControlArmedOrTriggered && isContentScrollableEnoughForImmersion { + // Check if we're at the very bottom + let contentHeight = scrollView.contentSize.height + let adjustedBottom = scrollView.adjustedContentInset.bottom + let maxOffsetY = max(contentHeight, scrollView.bounds.height - adjustedBottom) - scrollView.bounds.height + adjustedBottom + let distanceFromBottom = maxOffsetY - scrollView.contentOffset.y + + // Only snap to fully visible if we're at the very bottom AND bars are almost visible + // This prevents jarring snaps and lets the progressive reveal complete naturally + if distanceFromBottom <= 5 && immersionProgress < 0.15 { + immersionProgress = 0 + } + } + } + + /// Handle scroll view did end decelerating + func handleScrollViewDidEndDecelerating( + _ scrollView: UIScrollView, + isRefreshControlArmedOrTriggered: Bool + ) { + // Handle immersion mode completion when deceleration ends + if immersionModeEnabled && !isRefreshControlArmedOrTriggered && isContentScrollableEnoughForImmersion { + // Check if we're at the very bottom + let contentHeight = scrollView.contentSize.height + let adjustedBottom = scrollView.adjustedContentInset.bottom + let maxOffsetY = max(contentHeight, scrollView.bounds.height - adjustedBottom) - scrollView.bounds.height + adjustedBottom + let distanceFromBottom = maxOffsetY - scrollView.contentOffset.y + + // Only snap to fully visible if we're at the very bottom AND bars are almost visible + // This prevents jarring snaps and lets the progressive reveal complete naturally + if distanceFromBottom <= 5 && immersionProgress < 0.15 { + immersionProgress = 0 + } + } + } + + /// Main scroll handling logic for immersion mode + func handleScrollViewDidScroll( + _ scrollView: UIScrollView, + isDragging: Bool, + isDecelerating: Bool, + isRefreshControlArmedOrTriggered: Bool + ) { + // Handle immersion mode drawer-style behavior + guard immersionModeEnabled, + !UIAccessibility.isVoiceOverRunning, + (isDragging || isDecelerating), + !isRefreshControlArmedOrTriggered else { return } + + let currentOffset = scrollView.contentOffset.y + let scrollDelta = currentOffset - lastScrollOffset + + // Only proceed if content is scrollable enough for immersion mode + guard isContentScrollableEnoughForImmersion else { + // Force bars visible if content is too short + immersionProgress = 0 + lastScrollOffset = currentOffset + return + } + + // Dead zone at top to prevent jitter + if currentOffset < 20 { + // When very close to top, force bars to be fully visible + immersionProgress = 0 + lastScrollOffset = currentOffset + return + } + + // Minimum scroll delta threshold to prevent micro-movement responses + guard abs(scrollDelta) > 0.5 else { + // Ignore tiny movements that cause jitter + return + } + + let contentHeight = scrollView.contentSize.height + let adjustedBottom = scrollView.adjustedContentInset.bottom + let maxOffsetY = max(contentHeight, scrollView.bounds.height - adjustedBottom) - scrollView.bounds.height + adjustedBottom + + let barTravelDistance = totalBarTravelDistance + let distanceFromBottom = maxOffsetY - currentOffset + let nearBottomThreshold = barTravelDistance * 2.0 + let isNearBottom = distanceFromBottom <= nearBottomThreshold + + if isNearBottom && scrollDelta > 0 { + // Scrolling down toward bottom - progressively reveal bars + let targetProgress = (distanceFromBottom / nearBottomThreshold).clamp(0...1) + let incrementalProgress = immersionProgress + (scrollDelta / barTravelDistance) + immersionProgress = min(incrementalProgress, targetProgress).clamp(0...1) + } else { + // Normal 1:1 scroll response + let incrementalProgress = immersionProgress + (scrollDelta / barTravelDistance) + immersionProgress = incrementalProgress.clamp(0...1) + } + + lastScrollOffset = currentOffset + } + + // MARK: - Private Methods + + private func updateBarsForImmersionProgress() { + guard !isUpdatingBars else { return } + isUpdatingBars = true + defer { isUpdatingBars = false } + + CATransaction.begin() + CATransaction.setDisableActions(true) + + guard immersionModeEnabled && immersionProgress > 0 else { + safeAreaGradientView.alpha = 0.0 + resetAllTransforms() + CATransaction.commit() + return + } + + safeAreaGradientView.alpha = immersionProgress + + var navBarTransform: CGFloat = 0 + if let navBar = findNavigationBar() { + let navBarHeight = navBar.bounds.height + let deviceSafeAreaTop = postsView?.window?.safeAreaInsets.top ?? 44 + let totalUpwardDistance = navBarHeight + deviceSafeAreaTop + 30 + navBarTransform = -totalUpwardDistance * immersionProgress + navBar.transform = CGAffineTransform(translationX: 0, y: navBarTransform) + } + + topBarContainer?.transform = CGAffineTransform(translationX: 0, y: navBarTransform) + + if let toolbar = toolbar { + let toolbarHeight = toolbar.bounds.height + let deviceSafeAreaBottom = postsView?.window?.safeAreaInsets.bottom ?? 34 + let totalDownwardDistance = toolbarHeight + deviceSafeAreaBottom + toolbar.transform = CGAffineTransform(translationX: 0, y: totalDownwardDistance * immersionProgress) + } + + CATransaction.commit() + } + + /// Reset all transforms to identity + private func resetAllTransforms() { + if let foundNavBar = findNavigationBar() { + foundNavBar.transform = .identity + } + topBarContainer?.transform = .identity + toolbar?.transform = .identity + } + + private func updateScrollViewInsetsIfNeeded() { + postsView?.updateScrollViewInsets() + } + + private func findNavigationBar() -> UINavigationBar? { + if let cached = cachedNavigationBar { + return cached + } + + // Try to get it from the navigation controller we were configured with + if let navBar = navigationController?.navigationBar { + cachedNavigationBar = navBar + return navBar + } + + // Fallback: traverse responder chain from posts view + var responder: UIResponder? = postsView?.next + while responder != nil { + if let viewController = responder as? UIViewController, + let navBar = viewController.navigationController?.navigationBar { + cachedNavigationBar = navBar + return navBar + } + responder = responder?.next + } + + // Last resort: try to get from window's root view controller + if let window = postsView?.window, + let rootNav = window.rootViewController as? UINavigationController { + cachedNavigationBar = rootNav.navigationBar + return rootNav.navigationBar + } + + return nil + } + + /// Clear cached navigation bar when view hierarchy changes + func clearNavigationBarCache() { + cachedNavigationBar = nil + } +} + +// MARK: - Helper Extensions + +private extension Comparable { + func clamp(_ limits: ClosedRange) -> Self { + return min(max(self, limits.lowerBound), limits.upperBound) + } +} diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index 652dfc0aa..d20a45001 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -14,6 +14,13 @@ import UIKit - On iPhone, allows swiping from the *right* screen edge to unpop a view controller. */ final class NavigationController: UINavigationController, Themeable { + + /// Scroll progress thresholds for navigation bar appearance transitions + private enum ScrollProgress { + static let atTop: CGFloat = 0.01 + static let fullyScrolled: CGFloat = 0.99 + } + fileprivate weak var realDelegate: UINavigationControllerDelegate? fileprivate lazy var unpopHandler: UnpoppingViewHandler? = { guard UIDevice.current.userInterfaceIdiom == .phone else { return nil } @@ -46,13 +53,40 @@ final class NavigationController: UINavigationController, Themeable { return navigationBar as! NavigationBar } + @available(iOS 26.0, *) + private func createGradientBackgroundImage(from color: UIColor, size: CGSize = CGSize(width: 1, height: 96)) -> UIImage? { + let format = UIGraphicsImageRendererFormat() + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: size, format: format) + + return renderer.image { context in + let colorSpace = CGColorSpaceCreateDeviceRGB() + let colors = [color.cgColor, color.withAlphaComponent(0.0).cgColor] as CFArray + let locations: [CGFloat] = [0.0, 1.0] + + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: locations) else { + return + } + + let startPoint = CGPoint(x: 0, y: 0) + let endPoint = CGPoint(x: 0, y: size.height) + + context.cgContext.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: []) + } + } + var theme: Theme { + // Get theme from the top view controller if it's Themeable + if let themeableVC = topViewController as? Themeable { + return themeableVC.theme + } + // Fallback to default theme return Theme.defaultTheme() } - // MARK: set the status icons (clock, wifi, battery) to black or white depending on the mode of the theme - // thanks sarunw https://sarunw.com/posts/how-to-set-status-bar-style/ + // MARK: Status bar style management var isDarkContentBackground = false + var isScrolledFromTop = false func statusBarEnterLightBackground() { isDarkContentBackground = false @@ -65,6 +99,12 @@ final class NavigationController: UINavigationController, Themeable { } override var preferredStatusBarStyle: UIStatusBarStyle { + // For iOS 26+: use dynamic when scrolled + if #available(iOS 26.0, *), isScrolledFromTop { + return .default // Let system handle it dynamically when scrolled + } + + // Otherwise: follow the theme setting if isDarkContentBackground { return .lightContent } else { @@ -116,11 +156,185 @@ final class NavigationController: UINavigationController, Themeable { } func themeDidChange() { + updateNavigationBarAppearance(with: theme) + } + + /// Configures button appearance attributes for iOS 26 liquid glass compatibility. + /// Omits foregroundColor to allow navigationBar.tintColor to control button text color. + private func configureButtonAppearance(_ appearance: UINavigationBarAppearance, font: UIFont) { + let buttonAttributes: [NSAttributedString.Key: Any] = [.font: font] + appearance.buttonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.buttonAppearance.highlighted.titleTextAttributes = buttonAttributes + appearance.doneButtonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.doneButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + appearance.backButtonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.backButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + } + + @objc func updateNavigationBarTintForScrollProgress(_ progress: NSNumber) { + guard #available(iOS 26.0, *) else { return } + + let progressValue = CGFloat(progress.floatValue) + + updateNavigationBarBackgroundWithProgress(progressValue) + + if progressValue < ScrollProgress.atTop { + isScrolledFromTop = false + + if theme["statusBarBackground"] == "light" { + statusBarEnterLightBackground() + } else { + statusBarEnterDarkBackground() + } + } else if progressValue > ScrollProgress.fullyScrolled { + awfulNavigationBar.tintColor = nil + + if let topViewController = topViewController { + topViewController.navigationItem.leftBarButtonItem?.tintColor = nil + topViewController.navigationItem.rightBarButtonItem?.tintColor = nil + topViewController.navigationItem.leftBarButtonItems?.forEach { $0.tintColor = nil } + topViewController.navigationItem.rightBarButtonItems?.forEach { $0.tintColor = nil } + } + + isScrolledFromTop = true + setNeedsStatusBarAppearanceUpdate() + } + } + + @objc func updateNavigationBarTintForScrollPosition(_ isAtTop: NSNumber) { + guard #available(iOS 26.0, *) else { return } + let progress = isAtTop.boolValue ? 0.0 : 1.0 + updateNavigationBarTintForScrollProgress(NSNumber(value: progress)) + } + + /// Updates the navigation bar appearance based on scroll progress for iOS 26+ liquid glass effect. + /// + /// This method creates a dynamic navigation bar that transitions between three states: + /// - At top (progress < 0.01): Opaque background with theme colors + /// - Fully scrolled (progress > 0.99): Transparent background with system-provided contrasting colors + /// - Mid-scroll (0.01...0.99): Gradient transition between opaque and transparent states + /// + /// The dynamic appearance ensures optimal button visibility by letting the system adapt + /// colors to content underneath when scrolled, while maintaining theme consistency at the top. + /// + /// - Parameter progress: Scroll progress value from 0.0 (at top) to 1.0 (fully scrolled) + @available(iOS 26.0, *) + private func updateNavigationBarBackgroundWithProgress(_ progress: CGFloat) { + let appearance = UINavigationBarAppearance() + + if progress < ScrollProgress.atTop { + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme["navigationBarTintColor"] + } else if progress > ScrollProgress.fullyScrolled { + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.backgroundImage = nil + } else { + appearance.configureWithTransparentBackground() + + let opaqueColor: UIColor = theme["navigationBarTintColor"]! + let gradientBaseColor: UIColor = theme["listHeaderBackgroundColor"]! + + if let gradientImage = createGradientBackgroundImage(from: gradientBaseColor) { + appearance.backgroundImage = gradientImage + let overlayAlpha = 1.0 - progress + appearance.backgroundColor = opaqueColor.withAlphaComponent(overlayAlpha) + } else { + appearance.backgroundColor = interpolateColor(from: opaqueColor, to: gradientBaseColor, progress: progress) + } + } + + appearance.shadowColor = nil + appearance.shadowImage = nil + + if progress > ScrollProgress.fullyScrolled { + if let backImage = UIImage(named: "back")?.withTintColor(.label, renderingMode: .alwaysOriginal) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + } else { + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + } + + if progress < ScrollProgress.atTop { + let textColor: UIColor = theme["navigationBarTextColor"]! + + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + configureButtonAppearance(appearance, font: buttonFont) + } + else if progress > ScrollProgress.fullyScrolled { + let textColor: UIColor = theme["mode"] == "dark" ? .white : .black + + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + configureButtonAppearance(appearance, font: buttonFont) + } else { + let textColor: UIColor = theme["navigationBarTextColor"]! + + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + configureButtonAppearance(appearance, font: buttonFont) + } + + awfulNavigationBar.standardAppearance = appearance + awfulNavigationBar.scrollEdgeAppearance = appearance + awfulNavigationBar.compactAppearance = appearance + awfulNavigationBar.compactScrollEdgeAppearance = appearance + + if progress < ScrollProgress.atTop { + awfulNavigationBar.tintColor = theme["mode"] == "dark" ? .white : .black + } else if progress > ScrollProgress.fullyScrolled { + awfulNavigationBar.tintColor = nil + } + } + + private func interpolateColor(from startColor: UIColor, to endColor: UIColor, progress: CGFloat) -> UIColor { + let progress = max(0, min(1, progress)) // Clamp to 0-1 + + var startRed: CGFloat = 0, startGreen: CGFloat = 0, startBlue: CGFloat = 0, startAlpha: CGFloat = 0 + var endRed: CGFloat = 0, endGreen: CGFloat = 0, endBlue: CGFloat = 0, endAlpha: CGFloat = 0 + + startColor.getRed(&startRed, green: &startGreen, blue: &startBlue, alpha: &startAlpha) + endColor.getRed(&endRed, green: &endGreen, blue: &endBlue, alpha: &endAlpha) + + let red = startRed + (endRed - startRed) * progress + let green = startGreen + (endGreen - startGreen) * progress + let blue = startBlue + (endBlue - startBlue) * progress + let alpha = startAlpha + (endAlpha - startAlpha) * progress + + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } + + private func updateNavigationBarAppearance(with theme: Theme, for viewController: UIViewController? = nil) { awfulNavigationBar.barTintColor = theme["navigationBarTintColor"] - awfulNavigationBar.bottomBorderColor = theme["topBarBottomBorderColor"] - awfulNavigationBar.layer.shadowOpacity = Float(theme[double: "navigationBarShadowOpacity"] ?? 1) - awfulNavigationBar.tintColor = theme["navigationBarTextColor"] + + // iOS 26: Hide bottom border for liquid glass effect, earlier versions show themed border + if #available(iOS 26.0, *) { + awfulNavigationBar.bottomBorderColor = .clear + } else { + awfulNavigationBar.bottomBorderColor = theme["topBarBottomBorderColor"] + } + + // iOS 26: Remove shadow for liquid glass effect, earlier versions use themed shadow + if #available(iOS 26.0, *) { + awfulNavigationBar.layer.shadowOpacity = 0 + awfulNavigationBar.layer.shadowColor = UIColor.clear.cgColor + } else { + awfulNavigationBar.layer.shadowOpacity = Float(theme[double: "navigationBarShadowOpacity"] ?? 1) + } + // Apply theme's status bar setting if theme["statusBarBackground"] == "light" { statusBarEnterLightBackground() } else { @@ -128,19 +342,78 @@ final class NavigationController: UINavigationController, Themeable { } if #available(iOS 15.0, *) { - // Fix odd grey navigation bar background when scrolled to top on iOS 15. - let appearance = UINavigationBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = theme["navigationBarTintColor"] - appearance.shadowColor = nil - appearance.shadowImage = nil - - let textColor: UIColor? = theme["navigationBarTextColor"] - appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: textColor!, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold)] - - navigationBar.standardAppearance = appearance; - navigationBar.scrollEdgeAppearance = navigationBar.standardAppearance + if #available(iOS 26.0, *) { + let initialAppearance = UINavigationBarAppearance() + initialAppearance.configureWithOpaqueBackground() + initialAppearance.backgroundColor = theme["navigationBarTintColor"] + initialAppearance.shadowColor = nil + initialAppearance.shadowImage = nil + + let textColor: UIColor = theme["navigationBarTextColor"]! + + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + initialAppearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + + initialAppearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + configureButtonAppearance(initialAppearance, font: buttonFont) + + awfulNavigationBar.standardAppearance = initialAppearance + awfulNavigationBar.scrollEdgeAppearance = initialAppearance + awfulNavigationBar.compactAppearance = initialAppearance + awfulNavigationBar.compactScrollEdgeAppearance = initialAppearance + + awfulNavigationBar.tintColor = nil + + awfulNavigationBar.setNeedsLayout() + awfulNavigationBar.layoutIfNeeded() + + } else { + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme["navigationBarTintColor"] + appearance.shadowColor = nil + appearance.shadowImage = nil + + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + + let textColor: UIColor = theme["navigationBarTextColor"]! + appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold)] + + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + configureButtonAppearance(appearance, font: buttonFont) + + awfulNavigationBar.standardAppearance = appearance + awfulNavigationBar.scrollEdgeAppearance = appearance + awfulNavigationBar.compactAppearance = appearance + awfulNavigationBar.compactScrollEdgeAppearance = appearance + + awfulNavigationBar.tintColor = textColor + + awfulNavigationBar.setNeedsLayout() + awfulNavigationBar.layoutIfNeeded() + } + } else { + let fallbackTextColor = theme[uicolor: "navigationBarTextColor"]! + let attrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: fallbackTextColor, + .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + ] + UIBarButtonItem.appearance().setTitleTextAttributes(attrs, for: .normal) + UIBarButtonItem.appearance().setTitleTextAttributes(attrs, for: .highlighted) + + if let backImage = UIImage(named: "back") { + let tintedBackImage = backImage.withRenderingMode(.alwaysTemplate) + navigationBar.backIndicatorImage = tintedBackImage + navigationBar.backIndicatorTransitionMaskImage = tintedBackImage + } } } @@ -207,6 +480,47 @@ extension NavigationController: UIGestureRecognizerDelegate { extension NavigationController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + + let vcTheme: Theme + if let themeableViewController = viewController as? Themeable { + vcTheme = themeableViewController.theme + updateNavigationBarAppearance(with: vcTheme, for: viewController) + } else { + vcTheme = theme + updateNavigationBarAppearance(with: vcTheme, for: viewController) + } + + if awfulNavigationBar.backIndicatorImage == nil { + awfulNavigationBar.backIndicatorImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) + awfulNavigationBar.backIndicatorTransitionMaskImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) + } + + if !isScrolledFromTop { + let textColor: UIColor = vcTheme["navigationBarTextColor"]! + + awfulNavigationBar.tintColor = textColor + + // Only set explicit bar button item colors on iOS < 26 + if #unavailable(iOS 26.0) { + viewController.navigationItem.leftBarButtonItem?.tintColor = textColor + viewController.navigationItem.rightBarButtonItem?.tintColor = textColor + viewController.navigationItem.leftBarButtonItems?.forEach { $0.tintColor = textColor } + viewController.navigationItem.rightBarButtonItems?.forEach { $0.tintColor = textColor } + + if viewControllers.count > 1 { + let previousVC = viewControllers[viewControllers.count - 2] + previousVC.navigationItem.backBarButtonItem?.tintColor = textColor + } + } + } + + awfulNavigationBar.setNeedsLayout() + awfulNavigationBar.layoutIfNeeded() + + if #available(iOS 26.0, *) { + isScrolledFromTop = false + } + if let unpopHandler = unpopHandler , animated { unpopHandler.navigationControllerDidBeginAnimating() diff --git a/App/View Controllers/Posts/GradientView.swift b/App/View Controllers/Posts/GradientView.swift new file mode 100644 index 000000000..8f9aec20b --- /dev/null +++ b/App/View Controllers/Posts/GradientView.swift @@ -0,0 +1,58 @@ +// GradientView.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US + +import AwfulTheming +import UIKit + +/// A UIView subclass that uses CAGradientLayer as its backing layer. +final class GradientView: UIView { + + override class var layerClass: AnyClass { + CAGradientLayer.self + } + + var gradientLayer: CAGradientLayer { + layer as! CAGradientLayer + } + + override init(frame: CGRect) { + super.init(frame: frame) + configureGradient() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureGradient() + } + + private func configureGradient() { + let isDarkMode = Theme.defaultTheme()[string: "mode"] == "dark" + + if isDarkMode { + gradientLayer.colors = [ + UIColor.black.cgColor, + UIColor.black.withAlphaComponent(0.8).cgColor, + UIColor.black.withAlphaComponent(0.4).cgColor, + UIColor.clear.cgColor + ] + gradientLayer.locations = [0.0, 0.3, 0.7, 1.0] + } else { + gradientLayer.colors = [ + UIColor.white.withAlphaComponent(0.8).cgColor, + UIColor.white.withAlphaComponent(0.6).cgColor, + UIColor.white.withAlphaComponent(0.2).cgColor, + UIColor.white.withAlphaComponent(0.02).cgColor, + UIColor.clear.cgColor + ] + gradientLayer.locations = [0.0, 0.4, 0.7, 0.9, 1.0] + } + + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0) + } + + func themeDidChange() { + configureGradient() + } +} diff --git a/App/View Controllers/Posts/PostsPageSettingsViewController.swift b/App/View Controllers/Posts/PostsPageSettingsViewController.swift index 81b8cce55..6a5894c52 100644 --- a/App/View Controllers/Posts/PostsPageSettingsViewController.swift +++ b/App/View Controllers/Posts/PostsPageSettingsViewController.swift @@ -16,6 +16,7 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati @FoilDefaultStorage(Settings.darkMode) private var darkMode @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics @FoilDefaultStorage(Settings.fontScale) private var fontScale + @FoilDefaultStorage(Settings.immersionModeEnabled) private var immersionModeEnabled @FoilDefaultStorage(Settings.showAvatars) private var showAvatars @FoilDefaultStorage(Settings.loadImages) private var showImages @@ -73,6 +74,17 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati } darkMode = sender.isOn } + + private var immersionModeStack: UIStackView? + private var immersionModeLabel: UILabel? + private var immersionModeSwitch: UISwitch? + + @objc private func toggleImmersionMode(_ sender: UISwitch) { + if enableHaptics { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + immersionModeEnabled = sender.isOn + } private lazy var fontScaleFormatter: NumberFormatter = { let formatter = NumberFormatter() @@ -118,6 +130,9 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati } .store(in: &cancellables) + // Using RunLoop.main instead of DispatchQueue.main is intentional here. + // This defers UI updates during scrolling (tracking mode) for better performance. + // Settings toggles are not time-critical and can wait until scrolling completes. $showAvatars .receive(on: RunLoop.main) .sink { [weak self] in self?.avatarsSwitch?.isOn = $0 } @@ -127,11 +142,65 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati .receive(on: RunLoop.main) .sink { [weak self] in self?.imagesSwitch?.isOn = $0 } .store(in: &cancellables) + + $immersionModeEnabled + .receive(on: RunLoop.main) + .sink { [weak self] in self?.immersionModeSwitch?.isOn = $0 } + .store(in: &cancellables) + + DispatchQueue.main.async { [weak self] in + self?.setupImmersionModeUI() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updatePreferredContentSize() + } + + private func setupImmersionModeUI() { + guard isViewLoaded, immersionModeStack == nil else { return } + + let label = UILabel() + label.text = "Immersion Mode" + label.font = UIFont.preferredFont(forTextStyle: .body) + label.textColor = theme["sheetTextColor"] ?? UIColor.label + immersionModeLabel = label + + let modeSwitch = UISwitch() + modeSwitch.isOn = immersionModeEnabled + modeSwitch.onTintColor = theme["settingsSwitchColor"] + modeSwitch.addTarget(self, action: #selector(toggleImmersionMode(_:)), for: .valueChanged) + immersionModeSwitch = modeSwitch + + let stack = UIStackView(arrangedSubviews: [label, modeSwitch]) + stack.axis = .horizontal + stack.distribution = .equalSpacing + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + immersionModeStack = stack + + if let darkModeStack = darkModeStack, + let parentStack = darkModeStack.superview as? UIStackView { + if let index = parentStack.arrangedSubviews.firstIndex(of: darkModeStack) { + parentStack.insertArrangedSubview(stack, at: index + 1) + } else { + parentStack.addArrangedSubview(stack) + } + } else { + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), + stack.heightAnchor.constraint(equalToConstant: 44) + ]) + } } private func updatePreferredContentSize() { let preferredHeight = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height - preferredContentSize = CGSize(width: 320, height: preferredHeight) + preferredContentSize = CGSize(width: 320, height: max(preferredHeight, 246)) } override func themeDidChange() { @@ -148,6 +217,9 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati for uiswitch in switches { uiswitch.onTintColor = theme["settingsSwitchColor"] } + + immersionModeLabel?.textColor = theme["sheetTextColor"] ?? UIColor.label + immersionModeSwitch?.onTintColor = theme["settingsSwitchColor"] } // MARK: UIAdaptivePresentationControllerDelegate diff --git a/App/View Controllers/Posts/PostsPageTopBar.swift b/App/View Controllers/Posts/PostsPageTopBar.swift index 475a176ff..317912685 100644 --- a/App/View Controllers/Posts/PostsPageTopBar.swift +++ b/App/View Controllers/Posts/PostsPageTopBar.swift @@ -6,7 +6,7 @@ import AwfulSettings import AwfulTheming import UIKit -final class PostsPageTopBar: UIView { +final class PostsPageTopBar: UIView, PostsPageTopBarProtocol { private lazy var stackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [parentForumButton, previousPostsButton, scrollToEndButton]) diff --git a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift new file mode 100644 index 000000000..bc8e2ffc9 --- /dev/null +++ b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift @@ -0,0 +1,184 @@ +// PostsPageTopBarLiquidGlass.swift +// +// Copyright 2025 Awful Contributors + +import AwfulSettings +import AwfulTheming +import UIKit + +@available(iOS 26.0, *) +final class PostsPageTopBarLiquidGlass: UIView, PostsPageTopBarProtocol { + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [parentForumButton, previousPostsButton, scrollToEndButton]) + stackView.distribution = .fillEqually + stackView.spacing = 12 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private lazy var parentForumButton: UIButton = { + let button = PostsPageTopBarLiquidGlass.createCapsuleButton() + button.accessibilityLabel = LocalizedString("posts-page.parent-forum-button.accessibility-label") + button.accessibilityHint = LocalizedString("posts-page.parent-forum-button.accessibility-hint") + button.setTitle(LocalizedString("posts-page.parent-forum-button.title"), for: .normal) + return button + }() + + private lazy var previousPostsButton: UIButton = { + let button = PostsPageTopBarLiquidGlass.createCapsuleButton() + button.accessibilityLabel = LocalizedString("posts-page.previous-posts-button.accessibility-label") + button.setTitle(LocalizedString("posts-page.previous-posts-button.title"), for: .normal) + return button + }() + + private let scrollToEndButton: UIButton = { + let button = PostsPageTopBarLiquidGlass.createCapsuleButton() + button.accessibilityLabel = LocalizedString("posts-page.scroll-to-end-button.accessibility-label") + button.setTitle(LocalizedString("posts-page.scroll-to-end-button.title"), for: .normal) + return button + }() + + @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics + + override init(frame: CGRect) { + super.init(frame: frame) + + parentForumButton.addTarget(self, action: #selector(didTapParentForum), for: .primaryActionTriggered) + previousPostsButton.addTarget(self, action: #selector(didTapPreviousPosts), for: .primaryActionTriggered) + scrollToEndButton.addTarget(self, action: #selector(didTapScrollToEnd), for: .primaryActionTriggered) + + addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + + parentForumButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), + previousPostsButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), + scrollToEndButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 32) + ]) + + updateButtonsEnabled() + } + + private static func createCapsuleButton() -> UIButton { + let button = GlassButton() + button.translatesAutoresizingMaskIntoConstraints = false + return button + } + + private class GlassButton: UIButton { + private let glassView: UIVisualEffectView + private let label: UILabel + + override init(frame: CGRect) { + let glassEffect = UIGlassEffect() + glassView = UIVisualEffectView(effect: glassEffect) + glassView.translatesAutoresizingMaskIntoConstraints = false + glassView.isUserInteractionEnabled = false + glassView.layer.cornerRadius = 16 + glassView.layer.masksToBounds = true + glassView.layer.cornerCurve = .continuous + glassView.layer.shadowOpacity = 0 + + label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFontForTextStyle(.footnote, sizeAdjustment: 0, weight: .medium) + label.numberOfLines = 1 + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.85 + label.lineBreakMode = .byTruncatingTail + label.textAlignment = .center + label.isUserInteractionEnabled = false + + super.init(frame: frame) + + backgroundColor = .clear + + insertSubview(glassView, at: 0) + glassView.contentView.addSubview(label) + + NSLayoutConstraint.activate([ + glassView.leadingAnchor.constraint(equalTo: leadingAnchor), + glassView.trailingAnchor.constraint(equalTo: trailingAnchor), + glassView.topAnchor.constraint(equalTo: topAnchor), + glassView.bottomAnchor.constraint(equalTo: bottomAnchor), + + label.leadingAnchor.constraint(equalTo: glassView.contentView.leadingAnchor, constant: 12), + label.trailingAnchor.constraint(equalTo: glassView.contentView.trailingAnchor, constant: -12), + label.topAnchor.constraint(equalTo: glassView.contentView.topAnchor, constant: 6), + label.bottomAnchor.constraint(equalTo: glassView.contentView.bottomAnchor, constant: -6), + + heightAnchor.constraint(greaterThanOrEqualToConstant: 32) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setTitle(_ title: String?, for state: UIControl.State) { + if state == .normal { + label.text = title + } + } + + override var isEnabled: Bool { + didSet { + label.alpha = isEnabled ? 1.0 : 0.5 + } + } + } + + func themeDidChange(_ theme: Theme) { + for button in [parentForumButton, previousPostsButton, scrollToEndButton] { + button.setNeedsUpdateConfiguration() + } + } + + private func updateButtonsEnabled() { + parentForumButton.isEnabled = goToParentForum != nil + previousPostsButton.isEnabled = showPreviousPosts != nil + scrollToEndButton.isEnabled = scrollToEnd != nil + } + + @objc private func didTapParentForum(_ sender: UIButton) { + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + goToParentForum?() + } + var goToParentForum: (() -> Void)? { + didSet { updateButtonsEnabled() } + } + + @objc private func didTapPreviousPosts(_ sender: UIButton) { + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + showPreviousPosts?() + } + var showPreviousPosts: (() -> Void)? { + didSet { updateButtonsEnabled() } + } + + @objc private func didTapScrollToEnd(_ sender: UIButton) { + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + scrollToEnd?() + } + var scrollToEnd: (() -> Void)? { + didSet { updateButtonsEnabled() } + } + + // MARK: Gunk + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + diff --git a/App/View Controllers/Posts/PostsPageTopBarProtocol.swift b/App/View Controllers/Posts/PostsPageTopBarProtocol.swift new file mode 100644 index 000000000..9e58fc2fd --- /dev/null +++ b/App/View Controllers/Posts/PostsPageTopBarProtocol.swift @@ -0,0 +1,14 @@ +// PostsPageTopBarProtocol.swift +// +// Copyright 2025 Awful Contributors + +import AwfulTheming +import UIKit + +protocol PostsPageTopBarProtocol: UIView { + var goToParentForum: (() -> Void)? { get set } + var showPreviousPosts: (() -> Void)? { get set } + var scrollToEnd: (() -> Void)? { get set } + + func themeDidChange(_ theme: Theme) +} diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index 2e9519cb5..81fdbd770 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -20,6 +20,8 @@ final class PostsPageView: UIView { @FoilDefaultStorage(Settings.darkMode) private var darkMode @FoilDefaultStorage(Settings.frogAndGhostEnabled) private var frogAndGhostEnabled var viewHasBeenScrolledOnce: Bool = false + + let immersionModeManager = ImmersionModeManager() // MARK: Loading view @@ -64,7 +66,6 @@ final class PostsPageView: UIView { refreshControl.topAnchor.constraint(equalTo: containerMargins.topAnchor), containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor)]) } else { - // arrow view is hidden behind the toolbar and revealed when pulled up if refreshControl is PostsPageRefreshArrowView { NSLayoutConstraint.activate([ refreshControl.leftAnchor.constraint(equalTo: containerMargins.leftAnchor), @@ -73,7 +74,6 @@ final class PostsPageView: UIView { containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor) ]) } - // spinner view is visible above the toolbar, before any scroll triggers occur if refreshControl is GetOutFrogRefreshSpinnerView { NSLayoutConstraint.activate([ refreshControl.leftAnchor.constraint(equalTo: containerMargins.leftAnchor), @@ -174,11 +174,29 @@ final class PostsPageView: UIView { // MARK: Top bar - var topBar: PostsPageTopBar { - return topBarContainer.topBar + var topBar: PostsPageTopBarProtocol { + return topBarContainer.topBar as! PostsPageTopBarProtocol } - private let topBarContainer = TopBarContainer(frame: CGRect(x: 0, y: 0, width: 320, height: 44) /* somewhat arbitrary size to avoid unhelpful unsatisfiable constraints console messages */) + let topBarContainer = TopBarContainer(frame: CGRect(x: 0, y: 0, width: 320, height: 44) /* somewhat arbitrary size to avoid unhelpful unsatisfiable constraints console messages */) + + func setGoToParentForum(_ callback: (() -> Void)?) { + if let topBar = topBarContainer.postsTopBar { + topBar.goToParentForum = callback + } + } + + func setShowPreviousPosts(_ callback: (() -> Void)?) { + if let topBar = topBarContainer.postsTopBar { + topBar.showPreviousPosts = callback + } + } + + func setScrollToEnd(_ callback: (() -> Void)?) { + if let topBar = topBarContainer.postsTopBar { + topBar.scrollToEnd = callback + } + } private var topBarState: TopBarState { didSet { @@ -216,6 +234,23 @@ final class PostsPageView: UIView { let toolbar = Toolbar(frame: CGRect(x: 0, y: 0, width: 320, height: 44) /* somewhat arbitrary size to avoid unhelpful unsatisfiable constraints console messages */) + private var safeAreaGradientView: GradientView { + return immersionModeManager.safeAreaGradientView + } + + private lazy var fallbackSafeAreaGradientView: GradientView = { + let view = GradientView() + view.isUserInteractionEnabled = false + if #available(iOS 26.0, *) { + view.alpha = 1.0 + view.isHidden = false + } else { + view.alpha = 0.0 + view.isHidden = true + } + return view + }() + var toolbarItems: [UIBarButtonItem] { get { return toolbar.items ?? [] } set { toolbar.items = newValue } @@ -231,13 +266,26 @@ final class PostsPageView: UIView { NotificationCenter.default.addObserver(self, selector: #selector(voiceOverStatusDidChange), name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil) toolbar.overrideUserInterfaceStyle = Theme.defaultTheme()["mode"] == "light" ? .light : .dark - + addSubview(renderView) + if #available(iOS 26.0, *) { + addSubview(immersionModeManager.safeAreaGradientView) + } else { + addSubview(fallbackSafeAreaGradientView) + } addSubview(topBarContainer) addSubview(loadingViewContainer) addSubview(toolbar) renderView.scrollView.addSubview(refreshControlContainer) + immersionModeManager.configure( + postsView: self, + navigationController: nil, // Will be set from PostsPageViewController + renderView: renderView, + toolbar: toolbar, + topBarContainer: topBarContainer + ) + scrollViewDelegateMux = ScrollViewDelegateMultiplexer(scrollView: renderView.scrollView) scrollViewDelegateMux?.addDelegate(self) } @@ -250,12 +298,18 @@ final class PostsPageView: UIView { renderView.frame = bounds loadingViewContainer.frame = bounds - let toolbarHeight = toolbar.sizeThatFits(bounds.size).height - toolbar.frame = CGRect( - x: bounds.minX, - y: bounds.maxY - layoutMargins.bottom - toolbarHeight, - width: bounds.width, - height: toolbarHeight) + if #available(iOS 26.0, *) { + immersionModeManager.updateGradientLayout(in: self) + } + + if toolbar.transform == .identity { + let toolbarHeight = toolbar.sizeThatFits(bounds.size).height + toolbar.frame = CGRect( + x: bounds.minX, + y: bounds.maxY - layoutMargins.bottom - toolbarHeight, + width: bounds.width, + height: toolbarHeight) + } let scrollView = renderView.scrollView @@ -267,25 +321,55 @@ final class PostsPageView: UIView { height: refreshControlHeight) let topBarHeight = topBarContainer.layoutFittingCompressedHeight(targetWidth: bounds.width) + + // Position topBarContainer based on immersion mode state + let topBarY: CGFloat + if immersionModeManager.shouldPositionTopBarForImmersion() { + // In immersion mode, position to attach directly to navigation bar + topBarY = immersionModeManager.calculateTopBarY(normalY: bounds.minY + layoutMargins.top) + } else { + // Normal positioning + topBarY = bounds.minY + layoutMargins.top + } + topBarContainer.frame = CGRect( x: bounds.minX, - y: bounds.minY + layoutMargins.top, + y: topBarY, width: bounds.width, height: topBarHeight) updateTopBarContainerFrameAndScrollViewInsets() + + immersionModeManager.reapplyTransformsAfterLayout() } /// Assumes that various views (top bar container, refresh control container, toolbar) have been laid out. - private func updateScrollViewInsets() { + func updateScrollViewInsets() { let scrollView = renderView.scrollView - var contentInset = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: bounds.maxY - toolbar.frame.minY, right: 0) + // Calculate bottom inset based on immersion mode state + let bottomInset: CGFloat + if immersionModeManager.shouldAdjustScrollInsets() { + let normalInset = bounds.maxY - toolbar.frame.minY + bottomInset = immersionModeManager.calculateBottomInset(normalBottomInset: normalInset) + } else { + bottomInset = bounds.maxY - toolbar.frame.minY + } + + var contentInset = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: bottomInset, right: 0) if case .refreshing = refreshControlState { contentInset.bottom += refreshControlContainer.bounds.height } scrollView.contentInset = contentInset - var indicatorInsets = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: bounds.maxY - toolbar.frame.minY, right: 0) + // Calculate indicator bottom inset based on immersion mode state + let indicatorBottomInset: CGFloat + if immersionModeManager.shouldAdjustScrollInsets() { + indicatorBottomInset = immersionModeManager.calculateBottomInset(normalBottomInset: bounds.maxY - toolbar.frame.minY) + } else { + indicatorBottomInset = bounds.maxY - toolbar.frame.minY + } + + var indicatorInsets = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: indicatorBottomInset, right: 0) // I'm not sure if this is a bug or if I'm misunderstanding something, but as of iOS 12 it seems that the indicator insets have already taken the layout margins into consideration? That's my guess based on observing their positioning when the indicator insets are set to zero. indicatorInsets.top -= layoutMargins.top indicatorInsets.bottom -= layoutMargins.bottom @@ -322,11 +406,19 @@ final class PostsPageView: UIView { renderView.scrollView.indicatorStyle = theme.scrollIndicatorStyle renderView.setThemeStylesheet(theme["postsViewCSS"] ?? "") - toolbar.tintColor = Theme.defaultTheme()["toolbarTextColor"]! - toolbar.topBorderColor = Theme.defaultTheme()["bottomBarTopBorderColor"] - toolbar.isTranslucent = Theme.defaultTheme()[bool: "tabBarIsTranslucent"] ?? false + if #available(iOS 26.0, *) { + toolbar.isTranslucent = Theme.defaultTheme()[bool: "tabBarIsTranslucent"] ?? false + } else { + toolbar.tintColor = Theme.defaultTheme()["toolbarTextColor"]! + toolbar.topBorderColor = Theme.defaultTheme()["bottomBarTopBorderColor"] + toolbar.isTranslucent = Theme.defaultTheme()[bool: "tabBarIsTranslucent"] ?? false + } topBar.themeDidChange(Theme.defaultTheme()) + + if #available(iOS 26.0, *) { + safeAreaGradientView.themeDidChange() + } } // MARK: Gunk @@ -343,11 +435,24 @@ extension PostsPageView { /// Holds the top bar and clips to bounds, so the top bar doesn't sit behind a possibly-translucent navigation bar and obscure the underlying content. final class TopBarContainer: UIView { - fileprivate lazy var topBar: PostsPageTopBar = { - let topBar = PostsPageTopBar() - topBar.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true - return topBar + private var topBarHeightConstraint: NSLayoutConstraint? + private var isTopBarRemoved = false + + fileprivate lazy var topBar: UIView = { + let topBar: UIView & PostsPageTopBarProtocol + if #available(iOS 26.0, *) { + topBar = PostsPageTopBarLiquidGlass() + } else { + topBar = PostsPageTopBar() + } + topBarHeightConstraint = topBar.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) + topBarHeightConstraint?.isActive = true + return topBar }() + + var postsTopBar: PostsPageTopBarProtocol? { + return topBar as? PostsPageTopBarProtocol + } override init(frame: CGRect) { @@ -355,6 +460,10 @@ extension PostsPageView { clipsToBounds = true + if #available(iOS 26.0, *) { + backgroundColor = .clear + } + addSubview(topBar, constrainEdges: [.bottom, .left, .right]) } @@ -458,6 +567,16 @@ extension PostsPageView { /// A refresh has been triggered, the handler has been called, and a refreshing animation should continue until `endRefreshing()` is called. case refreshing + + /// Helper to check if the refresh control is in an active state + var isArmedOrTriggered: Bool { + switch self { + case .armed, .triggered: + return true + default: + return false + } + } } private struct ScrollViewInfo { @@ -500,34 +619,49 @@ extension PostsPageView { extension PostsPageView: ScrollViewDelegateExtras { func scrollViewDidChangeContentSize(_ scrollView: UIScrollView) { setNeedsLayout() + + immersionModeManager.handleScrollViewDidChangeContentSize(scrollView) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { willBeginDraggingContentOffset = scrollView.contentOffset - - // disable transparency so that scroll thumbs work in dark mode + if darkMode, !viewHasBeenScrolledOnce { renderView.toggleOpaqueToFixIOS15ScrollThumbColor(setOpaqueTo: true) viewHasBeenScrolledOnce = true } + + // Delegate immersion mode handling to manager + immersionModeManager.handleScrollViewWillBeginDragging(scrollView) } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + + immersionModeManager.handleScrollViewWillEndDragging( + scrollView, + withVelocity: velocity, + targetContentOffset: targetContentOffset, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) + switch refreshControlState { case .armed, .triggered: // Top bar shouldn't fight with refresh control. break case .ready, .awaitingScrollEnd, .refreshing, .disabled: - switch topBarState { - case .hidden where velocity.y < 0: - topBarState = .appearing(fromContentOffset: scrollView.contentOffset) + // Only handle top bar if immersion mode is disabled + if !immersionModeManager.shouldPositionTopBarForImmersion() { + switch topBarState { + case .hidden where velocity.y < 0: + topBarState = .appearing(fromContentOffset: scrollView.contentOffset) - case .visible where velocity.y > 0: - topBarState = .disappearing(fromContentOffset: scrollView.contentOffset) + case .visible where velocity.y > 0: + topBarState = .disappearing(fromContentOffset: scrollView.contentOffset) - case .hidden, .visible, .appearing, .disappearing, .alwaysVisible: - break + case .hidden, .visible, .appearing, .disappearing, .alwaysVisible: + break + } } } } @@ -549,6 +683,12 @@ extension PostsPageView: ScrollViewDelegateExtras { if !willDecelerate { updateTopBarDidEndDecelerating() + + immersionModeManager.handleScrollViewDidEndDragging( + scrollView, + willDecelerate: willDecelerate, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) } willBeginDraggingContentOffset = nil @@ -564,6 +704,11 @@ extension PostsPageView: ScrollViewDelegateExtras { } updateTopBarDidEndDecelerating() + + immersionModeManager.handleScrollViewDidEndDecelerating( + scrollView, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) } private func updateTopBarDidEndDecelerating() { @@ -612,6 +757,13 @@ extension PostsPageView: ScrollViewDelegateExtras { break } + immersionModeManager.handleScrollViewDidScroll( + scrollView, + isDragging: scrollView.isDragging, + isDecelerating: scrollView.isDecelerating, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) + switch topBarState { case .appearing, .disappearing: updateTopBarContainerFrameAndScrollViewInsets() @@ -642,5 +794,35 @@ extension PostsPageView: ScrollViewDelegateExtras { break } } + + if #available(iOS 26.0, *) { + let topInset = scrollView.adjustedContentInset.top + let currentOffset = scrollView.contentOffset.y + let topPosition = -topInset + + let transitionDistance: CGFloat = 30.0 + + let progress: CGFloat + if currentOffset <= topPosition { + progress = 0.0 + } else if currentOffset >= topPosition + transitionDistance { + progress = 1.0 + } else { + let distanceFromTop = currentOffset - topPosition + progress = distanceFromTop / transitionDistance + } + + var responder: UIResponder? = self + while responder != nil { + if let viewController = responder as? PostsPageViewController, + let navController = viewController.navigationController as? NavigationController { + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) + viewController.updateTitleViewTextColorForScrollProgress(progress) + + break + } + responder = responder?.next + } + } } } diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 31e12e867..303e7db56 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -41,8 +41,6 @@ final class PostsPageViewController: ViewController { @FoilDefaultStorage(Settings.jumpToPostEndOnDoubleTap) private var jumpToPostEndOnDoubleTap private var jumpToPostIDAfterLoading: String? private var messageViewController: MessageComposeViewController? - // Stored as Any because Task returns non-Sendable Core Data objects (Post: NSManagedObject). - // Swift 6 requires Task Success types to be Sendable. private var networkOperation: Any? private var observers: [NSKeyValueObservation] = [] private lazy var oEmbedFetcher: OEmbedFetcher = .init() @@ -56,6 +54,25 @@ final class PostsPageViewController: ViewController { let thread: AwfulThread private var webViewDidLoadOnce = false + private var _liquidGlassTitleView: UIView? + + @available(iOS 26.0, *) + private var liquidGlassTitleView: LiquidGlassTitleView? { + if _liquidGlassTitleView == nil { + _liquidGlassTitleView = LiquidGlassTitleView() + } + return _liquidGlassTitleView as? LiquidGlassTitleView + } + + @available(iOS 26.0, *) + func updateTitleViewTextColorForScrollProgress(_ progress: CGFloat) { + if progress < 0.01 { + liquidGlassTitleView?.textColor = theme["mode"] == "dark" ? .white : .black + } else if progress > 0.99 { + liquidGlassTitleView?.textColor = nil + } + } + func threadActionsMenu() -> UIMenu { return UIMenu(title: thread.title ?? "", image: nil, identifier: nil, options: .displayInline, children: [ // Bookmark @@ -183,7 +200,26 @@ final class PostsPageViewController: ViewController { } override var title: String? { - didSet { navigationItem.titleLabel.text = title } + didSet { + if #available(iOS 26.0, *) { + let glassView = liquidGlassTitleView + glassView?.title = title + glassView?.textColor = theme["mode"] == "dark" ? .white : .black + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) + default: + glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) + } + + navigationItem.titleView = glassView + configureNavigationBarForLiquidGlass() + } else { + navigationItem.titleView = nil + navigationItem.titleLabel.text = title + } + } } /** @@ -334,7 +370,12 @@ final class PostsPageViewController: ViewController { case .last where self.posts.isEmpty, .nextUnread where self.posts.isEmpty: let pageCount = self.numberOfPages > 0 ? "\(self.numberOfPages)" : "?" - self.currentPageItem.title = "Page ? of \(pageCount)" + if #available(iOS 26.0, *) { + self.pageNumberView.currentPage = 0 + self.pageNumberView.totalPages = self.numberOfPages > 0 ? self.numberOfPages : 0 + } else { + self.currentPageItem.title = "Page ? of \(pageCount)" + } case .last, .nextUnread, .specific: break @@ -532,6 +573,14 @@ final class PostsPageViewController: ViewController { return item }() + private lazy var pageNumberView: PageNumberView = { + let view = PageNumberView() + view.onTap = { [weak self] in + self?.handlePageNumberTap() + } + return view + }() + private lazy var currentPageItem: UIBarButtonItem = { let item = UIBarButtonItem(primaryAction: UIAction { [unowned self] action in guard self.postsView.loadingView == nil else { return } @@ -542,7 +591,22 @@ final class PostsPageViewController: ViewController { popover.barButtonItem = action.sender as? UIBarButtonItem } }) - item.possibleTitles = ["2345 / 2345"] + + if #available(iOS 26.0, *) { + let containerView = UIView() + containerView.addSubview(pageNumberView) + pageNumberView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + pageNumberView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + pageNumberView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + containerView.widthAnchor.constraint(equalTo: pageNumberView.widthAnchor, constant: 2), + containerView.heightAnchor.constraint(equalTo: pageNumberView.heightAnchor, constant: 2) + ]) + item.customView = containerView + } else { + item.possibleTitles = ["2345 / 2345"] + } + item.accessibilityHint = "Opens page picker" return item }() @@ -559,16 +623,42 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Next page" + + if #available(iOS 26.0, *) { + } else { + item.tintColor = theme["toolbarTextColor"] + } return item }() - private func actionsItem() -> UIBarButtonItem { - let buttonItem = UIBarButtonItem(title: "Menu", image: UIImage(named: "steamed-ham"), menu: threadActionsMenu()) - if #available(iOS 16.0, *) { - buttonItem.preferredMenuElementOrder = .fixed + // Use primaryAction like the other toolbar buttons + let item = UIBarButtonItem(primaryAction: UIAction( + image: UIImage(named: "steamed-ham"), + handler: { [unowned self] action in + if self.enableHaptics { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + + // Get the sender and find its frame + if let barButtonItem = action.sender as? UIBarButtonItem, + let view = barButtonItem.value(forKey: "view") as? UIView { + let buttonFrameInView = view.convert(view.bounds, to: self.view) + self.hiddenMenuButton.show(menu: self.threadActionsMenu(), from: buttonFrameInView) + } else { + // Fallback position + let frame = CGRect(x: self.view.bounds.width - 60, y: self.view.bounds.height - 100, width: 44, height: 44) + self.hiddenMenuButton.show(menu: self.threadActionsMenu(), from: frame) + } + } + )) + item.accessibilityLabel = "Thread actions" + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + } else { + item.tintColor = theme["toolbarTextColor"] } - return buttonItem + return item } private func refetchPosts() { @@ -594,7 +684,7 @@ final class PostsPageViewController: ViewController { do { posts = try context.fetch(request) } catch { - print("\(#function) error fetching posts: \(error)") + logger.error("\(#function) error fetching posts: \(error)") } } @@ -640,11 +730,23 @@ final class PostsPageViewController: ViewController { }() if case .specific(let pageNumber)? = page, numberOfPages > 0 { - currentPageItem.title = "\(pageNumber) / \(numberOfPages)" - currentPageItem.accessibilityLabel = "Page \(pageNumber) of \(numberOfPages)" - currentPageItem.setTitleTextAttributes([.font: UIFont.preferredFontForTextStyle(.body, weight: .medium)], for: .normal) + if #available(iOS 26.0, *) { + pageNumberView.currentPage = pageNumber + pageNumberView.totalPages = numberOfPages + currentPageItem.accessibilityLabel = "Page \(pageNumber) of \(numberOfPages)" + } else { + currentPageItem.title = "\(pageNumber) / \(numberOfPages)" + currentPageItem.accessibilityLabel = "Page \(pageNumber) of \(numberOfPages)" + currentPageItem.setTitleTextAttributes([.font: UIFont.preferredFontForTextStyle(.body, weight: .regular)], for: .normal) + } } else { - currentPageItem.title = "" + // Clear page display + if #available(iOS 26.0, *) { + pageNumberView.currentPage = 0 + pageNumberView.totalPages = 0 + } else { + currentPageItem.title = "" + } currentPageItem.accessibilityLabel = nil } @@ -658,6 +760,17 @@ final class PostsPageViewController: ViewController { }() composeItem.isEnabled = !thread.closed + + updateToolbarItems() + } + + private func updateToolbarItems() { + var toolbarItems: [UIBarButtonItem] = [settingsItem, .flexibleSpace()] + + toolbarItems.append(contentsOf: [backItem, currentPageItem, forwardItem]) + + toolbarItems.append(contentsOf: [.flexibleSpace(), actionsItem()]) + postsView.toolbarItems = toolbarItems } private func showLoadingView() { @@ -701,6 +814,18 @@ final class PostsPageViewController: ViewController { } } + private func handlePageNumberTap() { + guard postsView.loadingView == nil else { return } + let selectotron = Selectotron(postsViewController: self) + present(selectotron, animated: true) + + // For popover presentation with custom view, we need to set sourceView and sourceRect + if let popover = selectotron.popoverPresentationController { + popover.sourceView = pageNumberView + popover.sourceRect = pageNumberView.bounds + } + } + @objc private func loadPreviousPage(_ sender: UIKeyCommand) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() @@ -1040,10 +1165,7 @@ final class PostsPageViewController: ViewController { overlay.dismiss(true) // update toolbar so menu reflects new bookmarked state - var newItems = postsView.toolbarItems - newItems.removeLast() - newItems.append(actionsItem()) - postsView.toolbarItems = newItems + updateToolbarItems() } } catch { logger.error("error marking thread: \(error)") @@ -1480,16 +1602,48 @@ final class PostsPageViewController: ViewController { super.themeDidChange() postsView.themeDidChange(theme) - navigationItem.titleLabel.textColor = theme["navigationBarTextColor"] + + // Update title appearance for iOS 26+ + if #available(iOS 26.0, *) { + let glassView = liquidGlassTitleView + // Set both text color and font from theme + glassView?.textColor = theme["mode"] == "dark" ? .white : .black + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) + default: + glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) + } - switch UIDevice.current.userInterfaceIdiom { - case .pad: - navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) - navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! - default: - navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) - navigationItem.titleLabel.numberOfLines = 2 - navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! + // Update navigation bar configuration based on new theme + configureNavigationBarForLiquidGlass() + } else { + // Apply theme to regular title label for iOS < 26 + navigationItem.titleLabel.textColor = theme["navigationBarTextColor"] + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) + navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! + default: + navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) + navigationItem.titleLabel.numberOfLines = 2 + navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! + } + } + + // Update navigation bar button colors (only for iOS < 26) + if #available(iOS 26.0, *) { + } else { + composeItem.tintColor = theme["navigationBarTextColor"] + // Ensure the navigation bar itself uses the correct tint color for the back button + navigationController?.navigationBar.tintColor = theme["navigationBarTextColor"] + } + + // Also trigger the navigation controller's theme change to update back button appearance + if let navController = navigationController as? NavigationController { + navController.themeDidChange() } @@ -1509,13 +1663,20 @@ final class PostsPageViewController: ViewController { postsView.toolbar.standardAppearance = appearance postsView.toolbar.compactAppearance = appearance + postsView.toolbar.scrollEdgeAppearance = appearance + postsView.toolbar.compactScrollEdgeAppearance = appearance - if #available(iOS 15.0, *) { - postsView.toolbar.scrollEdgeAppearance = appearance - postsView.toolbar.compactScrollEdgeAppearance = appearance + if #available(iOS 26.0, *) { + } else { + backItem.tintColor = theme["toolbarTextColor"] + forwardItem.tintColor = theme["toolbarTextColor"] + settingsItem.tintColor = theme["toolbarTextColor"] + pageNumberView.textColor = theme["toolbarTextColor"] ?? UIColor.systemBlue } - postsView.toolbar.overrideUserInterfaceStyle = theme["mode"] == "light" ? .light : .dark + pageNumberView.updateTheme() + + updateToolbarItems() messageViewController?.themeDidChange() } @@ -1536,11 +1697,15 @@ final class PostsPageViewController: ViewController { postsView.renderView.scrollView.contentInsetAdjustmentBehavior = .never view.addSubview(postsView, constrainEdges: .all) - let spacer: CGFloat = 12 - postsView.toolbarItems = [ - settingsItem, .flexibleSpace(), - backItem, .fixedSpace(spacer), currentPageItem, .fixedSpace(spacer), forwardItem, - .flexibleSpace(), actionsItem()] + // Configure the immersion mode manager with navigation controller + postsView.immersionModeManager.configure( + postsView: postsView, + navigationController: navigationController, + renderView: postsView.renderView, + toolbar: postsView.toolbar, + topBarContainer: postsView.topBarContainer + ) + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(didLongPressOnPostsView)) longPress.delegate = self @@ -1632,6 +1797,63 @@ final class PostsPageViewController: ViewController { // See commentary in `viewDidLoad()` about our layout strategy here. tl;dr layout margins were the highest-level approach available on all versions of iOS that Awful supported, so we'll use them exclusively to represent the safe area. Probably not necessary anymore. postsView.layoutMargins = view.safeAreaInsets } + + @available(iOS 26.0, *) + private func configureNavigationBarForLiquidGlass() { + guard let navigationBar = navigationController?.navigationBar else { return } + guard let navController = navigationController as? NavigationController else { return } + + // Hide the custom bottom border from NavigationBar for liquid glass effect + if let awfulNavigationBar = navigationBar as? NavigationBar { + awfulNavigationBar.bottomBorderColor = .clear + } + + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme["navigationBarTintColor"] + appearance.shadowColor = nil + appearance.shadowImage = nil + + let textColor: UIColor = theme["navigationBarTextColor"]! + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + let buttonAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColor, + .font: buttonFont + ] + appearance.buttonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.buttonAppearance.highlighted.titleTextAttributes = buttonAttributes + appearance.doneButtonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.doneButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + appearance.backButtonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.backButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.compactScrollEdgeAppearance = appearance + + let navTextColor: UIColor = theme["mode"] == "dark" ? .white : .black + navigationBar.tintColor = navTextColor + + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: 0.0)) + + navigationBar.setNeedsLayout() + navigationBar.layoutIfNeeded() + + if let previousVC = navigationController?.viewControllers.dropLast().last { + previousVC.navigationItem.backBarButtonItem?.tintColor = navTextColor + } + + } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -1639,10 +1861,47 @@ final class PostsPageViewController: ViewController { configureUserActivityIfPossible() } + // MARK: - ImmersiveModeViewController + + func exitImmersionMode() { + postsView.immersionModeManager.exitImmersionMode() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Reset navigation bar BEFORE the transition starts + // This ensures the bar is visible during swipe-back gestures + exitImmersionMode() + + // Immediately reset navigation bar transform + if let navigationBar = navigationController?.navigationBar { + navigationBar.transform = .identity + } + + // Ensure navigation bar is not hidden + if navigationController?.isNavigationBarHidden == true { + navigationController?.setNavigationBarHidden(false, animated: false) + } + } + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) userActivity = nil + + // Additional cleanup as fallback (in case viewWillDisappear wasn't called) + postsView.immersionModeManager.exitImmersionMode() + + // Reset navigation bar transform to identity to ensure it's visible + if let navigationBar = navigationController?.navigationBar { + navigationBar.transform = .identity + } + + // Also check if navigation bar was hidden via system API + if navigationController?.isNavigationBarHidden == true { + navigationController?.setNavigationBarHidden(false, animated: animated) + } } override func encodeRestorableState(with coder: NSCoder) { diff --git a/App/Views/LiquidGlassTitleView.swift b/App/Views/LiquidGlassTitleView.swift new file mode 100644 index 000000000..f29d7776d --- /dev/null +++ b/App/Views/LiquidGlassTitleView.swift @@ -0,0 +1,115 @@ +// LiquidGlassTitleView.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US + +import UIKit + +@available(iOS 26.0, *) +final class LiquidGlassTitleView: UIView { + + private var visualEffectView: UIVisualEffectView = { + let effect = UIGlassEffect() + let view = UIVisualEffectView(effect: effect) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.numberOfLines = 2 + label.adjustsFontForContentSizeCategory = true + label.lineBreakMode = .byWordWrapping + return label + }() + + var title: String? { + get { titleLabel.text } + set { + titleLabel.text = newValue + updateTitleDisplay() + } + } + + var textColor: UIColor? { + get { titleLabel.textColor } + set { titleLabel.textColor = newValue } + } + + var font: UIFont? { + get { titleLabel.font } + set { + titleLabel.font = newValue + updateTitleDisplay() + } + } + + func setUseDarkGlass(_ useDark: Bool) { + visualEffectView.overrideUserInterfaceStyle = useDark ? .dark : .unspecified + } + + private func updateTitleDisplay() { + guard let text = titleLabel.text, !text.isEmpty else { return } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + paragraphStyle.lineSpacing = -2 + paragraphStyle.lineBreakMode = .byWordWrapping + + let attributes: [NSAttributedString.Key: Any] = [ + .paragraphStyle: paragraphStyle, + .font: titleLabel.font ?? UIFont.preferredFont(forTextStyle: .callout) + ] + + titleLabel.attributedText = NSAttributedString(string: text, attributes: attributes) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + } + + private func setupViews() { + addSubview(visualEffectView) + visualEffectView.contentView.addSubview(titleLabel) + + NSLayoutConstraint.activate([ + visualEffectView.topAnchor.constraint(equalTo: topAnchor), + visualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + visualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + visualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + + titleLabel.topAnchor.constraint(equalTo: visualEffectView.contentView.topAnchor, constant: 8), + titleLabel.leadingAnchor.constraint(equalTo: visualEffectView.contentView.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: visualEffectView.contentView.trailingAnchor, constant: -16), + titleLabel.bottomAnchor.constraint(equalTo: visualEffectView.contentView.bottomAnchor, constant: -8) + ]) + + isAccessibilityElement = false + titleLabel.isAccessibilityElement = true + titleLabel.accessibilityTraits = .header + } + + override func layoutSubviews() { + super.layoutSubviews() + + let cornerRadius = bounds.height / 2 + visualEffectView.layer.cornerRadius = cornerRadius + visualEffectView.layer.masksToBounds = true + visualEffectView.layer.cornerCurve = .continuous + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: 320, height: 56) + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + return CGSize(width: 320, height: 56) + } +} diff --git a/App/Views/PageNumberView.swift b/App/Views/PageNumberView.swift new file mode 100644 index 000000000..174d246d3 --- /dev/null +++ b/App/Views/PageNumberView.swift @@ -0,0 +1,137 @@ +// PageNumberView.swift +// +// Copyright © 2025 Awful Contributors. All rights reserved. +// + +import UIKit + +final class PageNumberView: UIView { + private let pageLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.font = UIFont.preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + return label + }() + + var currentPage: Int = 1 { + didSet { + updateDisplay() + } + } + + var totalPages: Int = 1 { + didSet { + updateDisplay() + } + } + + var textColor: UIColor = { + return .label + }() { + didSet { + updateColors() + } + } + + var onTap: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + updateDisplay() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + updateDisplay() + } + + private func setupViews() { + addSubview(pageLabel) + isUserInteractionEnabled = true + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tapGesture) + + NSLayoutConstraint.activate([ + pageLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + pageLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + pageLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), + pageLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + widthAnchor.constraint(greaterThanOrEqualToConstant: 60), + heightAnchor.constraint(equalToConstant: { + if #available(iOS 26.0, *) { + return 39 + } else { + return 44 + } + }()) + ]) + + pageLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + updateColors() + } + + @objc private func handleTap() { + onTap?() + } + + private func updateDisplay() { + let formatter = NumberFormatter() + formatter.numberStyle = .none + formatter.usesGroupingSeparator = false + + let currentPageText: String + if currentPage == 0 { + currentPageText = "?" + } else { + currentPageText = formatter.string(from: NSNumber(value: currentPage)) ?? "\(currentPage)" + } + + let totalPagesText: String + if totalPages == 0 { + totalPagesText = "?" + } else { + totalPagesText = formatter.string(from: NSNumber(value: totalPages)) ?? "\(totalPages)" + } + + pageLabel.text = "\(currentPageText) / \(totalPagesText)" + accessibilityLabel = "Page \(currentPageText) of \(totalPagesText)" + accessibilityHint = "Opens page picker" + } + + private func updateColors() { + pageLabel.textColor = textColor + } + + func updateTheme() { + pageLabel.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: 0, weight: .regular) + } + + override var intrinsicContentSize: CGSize { + let labelSize = pageLabel.intrinsicContentSize + let height: CGFloat + if #available(iOS 26.0, *) { + height = 39 + } else { + height = 44 + } + return CGSize(width: max(labelSize.width, 60), height: height) + } + + override func layoutSubviews() { + super.layoutSubviews() + invalidateIntrinsicContentSize() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { + invalidateIntrinsicContentSize() + } + } +} diff --git a/App/Views/RenderView.swift b/App/Views/RenderView.swift index 2bc995bea..e88686e55 100644 --- a/App/Views/RenderView.swift +++ b/App/Views/RenderView.swift @@ -537,6 +537,28 @@ extension RenderView { } } } + + /// Scrolls smoothly to the identified post with proper offset for navigation bar. + func scrollToPost(identifiedBy postID: String, animated: Bool = true) { + let escapedPostID: String + do { + escapedPostID = try escapeForEval(postID) + } catch { + logger.warning("could not JSON-escape the post ID: \(error)") + return + } + + // Get the content inset top to pass as offset + let topOffset = scrollView.contentInset.top + + Task { + do { + try await webView.eval("if (window.Awful) Awful.scrollToPostWithID(\(escapedPostID), \(animated), \(topOffset))") + } catch { + self.mentionError(error, explanation: "could not evaluate scrollToPostWithID") + } + } + } /// Turns each link with a `data-awful-linkified-image` attribute into a a proper `img` element. func loadLinkifiedImages() { diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index b32003490..682d6bbe8 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -202,6 +202,12 @@ 2D265F8C292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */; }; 2D265F8F292CB447001336ED /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 2D265F8E292CB447001336ED /* Lottie */; }; 2D327DD627F468CE00D21AB0 /* BookmarkColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */; }; + 2D571B492EC8765F0026826C /* ImmersiveModeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B482EC876590026826C /* ImmersiveModeViewController.swift */; }; + 2D571B4B2EC876A20026826C /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4A2EC8769E0026826C /* GradientView.swift */; }; + 2D571B4D2EC878230026826C /* PostsPageTopBarProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4C2EC8781E0026826C /* PostsPageTopBarProtocol.swift */; }; + 2D571B4F2EC878A70026826C /* PostsPageTopBarLiquidGlass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */; }; + 2D571B512EC8795B0026826C /* LiquidGlassTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B502EC879590026826C /* LiquidGlassTitleView.swift */; }; + 2D571B532EC87B010026826C /* PageNumberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B522EC87AFE0026826C /* PageNumberView.swift */; }; 2D921269292F588100B16011 /* platinum-member.png in Resources */ = {isa = PBXBuildFile; fileRef = 2D921268292F588100B16011 /* platinum-member.png */; }; 2DAF1FE12E05D3ED006F6BC4 /* View+FontDesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */; }; 2DD8209C25DDD9BF0015A90D /* CopyImageActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */; }; @@ -517,11 +523,16 @@ 2D19BA3829C33302009DD94F /* toot60.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = toot60.json; sourceTree = ""; }; 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetOutFrogRefreshSpinnerView.swift; sourceTree = ""; }; 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkColorPicker.swift; sourceTree = ""; }; + 2D571B482EC876590026826C /* ImmersiveModeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveModeViewController.swift; sourceTree = ""; }; + 2D571B4A2EC8769E0026826C /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; + 2D571B4C2EC8781E0026826C /* PostsPageTopBarProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsPageTopBarProtocol.swift; sourceTree = ""; }; + 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsPageTopBarLiquidGlass.swift; sourceTree = ""; }; + 2D571B502EC879590026826C /* LiquidGlassTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassTitleView.swift; sourceTree = ""; }; + 2D571B522EC87AFE0026826C /* PageNumberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageNumberView.swift; sourceTree = ""; }; 2D921268292F588100B16011 /* platinum-member.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "platinum-member.png"; sourceTree = ""; }; 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+FontDesign.swift"; sourceTree = ""; }; 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CopyImageActivity.swift; sourceTree = ""; }; 30E0C5162E35C89D0030DC0A /* AnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; - 30E0C5172E35C89D0030DC0A /* SmilieData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieData.swift; sourceTree = ""; }; 30E0C5182E35C89D0030DC0A /* SmilieGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieGridItem.swift; sourceTree = ""; }; 30E0C5192E35C89D0030DC0A /* SmiliePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmiliePickerView.swift; sourceTree = ""; }; 30E0C51A2E35C89D0030DC0A /* SmilieSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieSearchViewModel.swift; sourceTree = ""; }; @@ -651,6 +662,7 @@ 1C00E0AB173A006B008895E7 /* Navigation */ = { isa = PBXGroup; children = ( + 2D571B482EC876590026826C /* ImmersiveModeViewController.swift */, 1C0D80011CF9FE79003EE2D1 /* NavigationBar.swift */, 1C0D80031CF9FE81003EE2D1 /* NavigationController.swift */, 1C0D7FFF1CF9FE70003EE2D1 /* Toolbar.swift */, @@ -748,6 +760,9 @@ 1C29C382225853A300E1217A /* Posts */ = { isa = PBXGroup; children = ( + 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */, + 2D571B4C2EC8781E0026826C /* PostsPageTopBarProtocol.swift */, + 2D571B4A2EC8769E0026826C /* GradientView.swift */, 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */, 1C16FC191CD42EB300C88BD1 /* PostPreviewViewController.swift */, 1C16FBE61CBC671A00C88BD1 /* PostRenderModel.swift */, @@ -1119,6 +1134,8 @@ 1CF7FACB1BB60C2200A077F2 /* Views */ = { isa = PBXGroup; children = ( + 2D571B522EC87AFE0026826C /* PageNumberView.swift */, + 2D571B502EC879590026826C /* LiquidGlassTitleView.swift */, 2D10A3842E05C35700544F91 /* SearchView.swift */, 1CC22AB419F972C200D5BABD /* HairlineView.swift */, 1C16FC011CC29B2C00C88BD1 /* LoadingView.swift */, @@ -1541,7 +1558,6 @@ 83410EF219A582B8002CD019 /* DateFormatters.swift in Sources */, 1C273A9E21B316DB002875A9 /* LoadMoreFooter.swift in Sources */, 1C2C1F0E1CE16FE200CD27DD /* CloseBBcodeTagCommand.swift in Sources */, - 30E0C51C2E35C89D0030DC0A /* SmilieData.swift in Sources */, 30E0C51D2E35C89D0030DC0A /* AnimatedImageView.swift in Sources */, 30E0C51E2E35C89D0030DC0A /* SmiliePickerView.swift in Sources */, 30E0C51F2E35C89D0030DC0A /* SmilieSearchViewModel.swift in Sources */, @@ -1552,6 +1568,7 @@ 1C16FBD91CBAA33600C88BD1 /* PunishmentCell.swift in Sources */, 1C16FBF61CBDC65C00C88BD1 /* CaseInsensitiveMatching.swift in Sources */, 1C16FC0E1CC477B200C88BD1 /* ThreadTagPickerViewController.swift in Sources */, + 2D571B532EC87B010026826C /* PageNumberView.swift in Sources */, 1C796A292218C41F0035E154 /* DefaultBrowser+.swift in Sources */, 1CB5F7F5201527550046D080 /* ThreadListCell.swift in Sources */, 1C25AC4B1F537A9600977D6F /* WebViewAntiHijacking.swift in Sources */, @@ -1588,6 +1605,7 @@ 1CC256B51A398084003FA7A8 /* ScrollViewKeyboardAvoider.swift in Sources */, 1C6821A91F113A760083F204 /* AnnouncementViewController.swift in Sources */, 1CB15BEE1A9D33F600176E73 /* URLMenuPresenter.swift in Sources */, + 2D571B512EC8795B0026826C /* LiquidGlassTitleView.swift in Sources */, 1C220E3D2B815AFC00DA92B0 /* Bundle+.swift in Sources */, 1CD0C54F1BE674D700C3AC80 /* PostsPageRefreshSpinnerView.swift in Sources */, 1CEB5BFF19AB9C1700C82C30 /* InAppActionViewController.swift in Sources */, @@ -1598,7 +1616,9 @@ 1CB5F7F7201547D90046D080 /* Thread+Presentation.swift in Sources */, 1C16FC181CD1848400C88BD1 /* ComposeTextViewController.swift in Sources */, 1CC256B11A38526B003FA7A8 /* MessageListViewController.swift in Sources */, + 2D571B4B2EC876A20026826C /* GradientView.swift in Sources */, 1CC065F72D67028F002BB6A0 /* AppIconImageNames.swift in Sources */, + 2D571B492EC8765F0026826C /* ImmersiveModeViewController.swift in Sources */, 1C0D80001CF9FE70003EE2D1 /* Toolbar.swift in Sources */, 1C16FC101CC6F19700C88BD1 /* ThreadPreviewViewController.swift in Sources */, 1C09BFF01A09D485007C11F5 /* InAppActionCollectionViewLayout.swift in Sources */, @@ -1630,6 +1650,7 @@ 1C0D7FFE1CF38CA2003EE2D1 /* PostsPageViewController.swift in Sources */, 1CC256B71A39A6BE003FA7A8 /* AwfulBrowser.swift in Sources */, 1CF280982055EB9B00913149 /* AwfulRoute.swift in Sources */, + 2D571B4D2EC878230026826C /* PostsPageTopBarProtocol.swift in Sources */, 1CD9FB641D1A38030070C8C7 /* NigglyRefreshView.swift in Sources */, 1CF6786E201E8F45009A9640 /* MessageListCell.swift in Sources */, 1C16FBB01CB863E600C88BD1 /* IconActionItem.swift in Sources */, @@ -1646,6 +1667,7 @@ 1C16FC121CC6FD8600C88BD1 /* ThreadComposeViewController.swift in Sources */, 1C8F680B222B8F06007E61ED /* NamedThreadTag.swift in Sources */, 1C24BC98200A9BE00022C85F /* ThreadListDataSource.swift in Sources */, + 2D571B4F2EC878A70026826C /* PostsPageTopBarLiquidGlass.swift in Sources */, 1C0D80041CF9FE81003EE2D1 /* NavigationController.swift in Sources */, 1C16FBB21CB86ACD00C88BD1 /* EmptyViewController.swift in Sources */, 1C353C071E416FE200CCBA51 /* SpriteSheetView.swift in Sources */, diff --git a/AwfulSettings/Sources/AwfulSettings/Settings.swift b/AwfulSettings/Sources/AwfulSettings/Settings.swift index 658defc3d..c8d84e36c 100644 --- a/AwfulSettings/Sources/AwfulSettings/Settings.swift +++ b/AwfulSettings/Sources/AwfulSettings/Settings.swift @@ -71,6 +71,9 @@ public enum Settings { /// Mode for Imgur image uploads (Off, Anonymous, or with Account) public static let imgurUploadMode = Setting(key: "imgur_upload_mode", default: ImgurUploadMode.default) + /// Hide navigation and toolbar when scrolling down in posts view. + public static let immersionModeEnabled = Setting(key: "immersion_mode_enabled", default: false) + /// What percentage to multiply the default post font size by. Stored as percentage points, i.e. default is `100` aka "100% size" aka the default. public static let fontScale = Setting(key: "font_scale", default: 100.0) diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index ad4d94f76..e4d03e736 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -117,6 +117,9 @@ }, "Imgur Uploads" : { + }, + "Immersion Mode" : { + }, "Links" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index ab34cb906..bf9f8daf7 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -24,6 +24,7 @@ public struct SettingsView: View { @AppStorage(Settings.frogAndGhostEnabled) private var frogAndGhostEnabled @AppStorage(Settings.handoffEnabled) private var handoffEnabled @AppStorage(Settings.hideSidebarInLandscape) private var hideSidebarInLandscape + @AppStorage(Settings.immersionModeEnabled) private var immersionModeEnabled @AppStorage(Settings.loadImages) private var loadImages @AppStorage(Settings.openTwitterLinksInTwitter) private var openLinksInTwitter @AppStorage(Settings.openYouTubeLinksInYouTube) private var openLinksInYouTube @@ -150,6 +151,7 @@ public struct SettingsView: View { Toggle("Embed Bluesky Posts", bundle: .module, isOn: $embedBlueskyPosts) Toggle("Embed Tweets", bundle: .module, isOn: $embedTweets) Toggle("Double-Tap Post to Jump", bundle: .module, isOn: $doubleTapPostToJump) + Toggle("Immersion Mode", bundle: .module, isOn: $immersionModeEnabled) Toggle("Enable Haptics", bundle: .module, isOn: $enableHaptics) if isPad { Toggle("Enable Custom Title Post Layout", bundle: .module, isOn: $customTitlePostLayout) From 36ad382edcdcfcb0b22952ab90f4cd52494593e1 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:57:59 +1100 Subject: [PATCH 02/11] First refactor session --- .../ImmersiveModeViewController.swift | 233 +++++++----------- App/Navigation/NavigationController.swift | 94 +++---- .../PostsPageSettingsViewController.swift | 38 +-- .../Posts/PostsPageTopBarLiquidGlass.swift | 4 +- .../Posts/PostsPageView.swift | 50 ++-- .../Posts/PostsPageViewController.swift | 16 +- App/Views/LiquidGlassTitleView.swift | 32 ++- App/Views/PageNumberView.swift | 35 ++- .../Sources/AwfulSettings/Settings.swift | 4 +- .../AwfulSettingsUI/Localizable.xcstrings | 2 +- .../AwfulSettingsUI/SettingsView.swift | 4 +- 11 files changed, 232 insertions(+), 280 deletions(-) diff --git a/App/Navigation/ImmersiveModeViewController.swift b/App/Navigation/ImmersiveModeViewController.swift index 515fba735..bf956639d 100644 --- a/App/Navigation/ImmersiveModeViewController.swift +++ b/App/Navigation/ImmersiveModeViewController.swift @@ -6,20 +6,22 @@ import Foundation import UIKit import AwfulSettings -/// Protocol for view controllers that support immersion mode -/// This allows NavigationController to properly handle transitions -@MainActor -protocol ImmersiveModeViewController { - /// Called when the view controller should exit immersion mode - /// This is typically called when transitioning away from the view controller - func exitImmersionMode() -} - -// MARK: - ImmersionModeManager +// MARK: - ImmersiveModeManager -/// Manages immersion mode behavior for posts view +/// Manages immersive mode behavior for posts view /// Handles hiding/showing navigation bars and toolbars with scroll gestures -final class ImmersionModeManager: NSObject { +final class ImmersiveModeManager: NSObject { + + // MARK: - Constants + + private static let bottomProximityDistance: CGFloat = 30 + private static let topProximityThreshold: CGFloat = 20 + private static let minScrollDelta: CGFloat = 0.5 + private static let snapToHiddenThreshold: CGFloat = 0.9 + private static let snapToVisibleThreshold: CGFloat = 0.1 + private static let bottomSnapThreshold: CGFloat = 0.15 + private static let bottomDistanceThreshold: CGFloat = 5.0 + private static let progressiveRevealMultiplier: CGFloat = 2.0 // MARK: - Dependencies @@ -40,16 +42,14 @@ final class ImmersionModeManager: NSObject { // MARK: - Configuration - /// Whether immersion mode is enabled in settings - @FoilDefaultStorage(Settings.immersionModeEnabled) private var immersionModeEnabled { + /// Whether immersive mode is enabled in settings + @FoilDefaultStorage(Settings.immersiveModeEnabled) private var immersiveModeEnabled { didSet { - if immersionModeEnabled && !oldValue { - // Just enabled - may need to update layout + if immersiveModeEnabled && !oldValue { postsView?.setNeedsLayout() postsView?.layoutIfNeeded() - } else if !immersionModeEnabled && oldValue { - // Just disabled - reset everything - immersionProgress = 0.0 + } else if !immersiveModeEnabled && oldValue { + immersiveProgress = 0.0 resetAllTransforms() safeAreaGradientView.alpha = 0.0 postsView?.setNeedsLayout() @@ -59,17 +59,17 @@ final class ImmersionModeManager: NSObject { // MARK: - State Properties - /// Progress of immersion mode (0.0 = bars fully visible, 1.0 = bars fully hidden) - private var immersionProgress: CGFloat = 0.0 { + /// Progress of immersive mode (0.0 = bars fully visible, 1.0 = bars fully hidden) + private var immersiveProgress: CGFloat = 0.0 { didSet { - guard immersionModeEnabled && !UIAccessibility.isVoiceOverRunning else { - immersionProgress = 0.0 + guard immersiveModeEnabled && !UIAccessibility.isVoiceOverRunning else { + immersiveProgress = 0.0 return } let oldProgress = oldValue - immersionProgress = immersionProgress.clamp(0...1) - if oldProgress != immersionProgress { - updateBarsForImmersionProgress() + immersiveProgress = immersiveProgress.clamp(0...1) + if oldProgress != immersiveProgress { + updateBarsForImmersiveProgress() } } } @@ -106,26 +106,20 @@ final class ImmersionModeManager: NSObject { if let navBar = findNavigationBar() { let navBarHeight = navBar.bounds.height let deviceSafeAreaTop = window.safeAreaInsets.top - let topDistance = navBarHeight + deviceSafeAreaTop + 30 + let topDistance = navBarHeight + deviceSafeAreaTop + Self.bottomProximityDistance return max(bottomDistance, topDistance) } return bottomDistance } - /// Check if content is scrollable enough to warrant immersion mode - private var isContentScrollableEnoughForImmersion: Bool { + /// Check if content is scrollable enough to warrant immersive mode + private var isContentScrollableEnoughForImmersive: Bool { guard let scrollView = renderView?.scrollView else { return false } let scrollableHeight = scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom return scrollableHeight > (totalBarTravelDistance * 2) } - // MARK: - Initialization - - override init() { - super.init() - } - // MARK: - Configuration /// Configure the manager with required view references @@ -148,33 +142,29 @@ final class ImmersionModeManager: NSObject { // MARK: - Public Methods - /// Force exit immersion mode (useful for scroll-to-top/bottom actions) - func exitImmersionMode() { - guard immersionModeEnabled && immersionProgress > 0 else { return } - immersionProgress = 0.0 + /// Force exit immersive mode (useful for scroll-to-top/bottom actions) + func exitImmersiveMode() { + guard immersiveModeEnabled && immersiveProgress > 0 else { return } + immersiveProgress = 0.0 - // Explicitly reset navigation bar transform when exiting immersion mode - // This ensures the navigation bar is visible when returning to previous view if let navBar = findNavigationBar() { navBar.transform = .identity } } - /// Check if immersion mode should affect scroll insets + /// Check if immersive mode should affect scroll insets func shouldAdjustScrollInsets() -> Bool { - return immersionModeEnabled + return immersiveModeEnabled } - /// Calculate bottom inset adjustment for immersion mode + /// Calculate bottom inset adjustment for immersive mode func calculateBottomInset(normalBottomInset: CGFloat) -> CGFloat { - guard immersionModeEnabled, + guard immersiveModeEnabled, let toolbar = toolbar, let postsView = postsView else { return normalBottomInset } - // During immersion mode, use the static toolbar position (without transforms) - // to keep contentInset constant and prevent scroll interference let toolbarHeight = toolbar.sizeThatFits(postsView.bounds.size).height let staticToolbarY = postsView.bounds.maxY - postsView.layoutMargins.bottom - toolbarHeight return max(postsView.layoutMargins.bottom, postsView.bounds.maxY - staticToolbarY) @@ -193,28 +183,25 @@ final class ImmersionModeManager: NSObject { ) } - /// Apply immersion transforms after layout if needed + /// Apply immersive transforms after layout if needed func reapplyTransformsAfterLayout() { - if immersionModeEnabled && immersionProgress > 0 { - updateBarsForImmersionProgress() + if immersiveModeEnabled && immersiveProgress > 0 { + updateBarsForImmersiveProgress() } } - /// Determine if top bar should be positioned for immersion mode - func shouldPositionTopBarForImmersion() -> Bool { - return immersionModeEnabled + /// Determine if top bar should be positioned for immersive mode + func shouldPositionTopBarForImmersive() -> Bool { + return immersiveModeEnabled } - /// Calculate top bar Y position for immersion mode + /// Calculate top bar Y position for immersive mode func calculateTopBarY(normalY: CGFloat) -> CGFloat { - guard immersionModeEnabled else { return normalY } + guard immersiveModeEnabled else { return normalY } - // In immersion mode, position it to attach directly to the bottom edge of the navigation bar if let navBar = findNavigationBar() { - // Position directly at the bottom edge of the nav bar (no gap) return navBar.frame.maxY } else { - // Fallback to estimated position return postsView?.bounds.minY ?? 0 + (postsView?.layoutMargins.top ?? 0) + 44 } } @@ -223,10 +210,8 @@ final class ImmersionModeManager: NSObject { /// Handle scroll view content size changes func handleScrollViewDidChangeContentSize(_ scrollView: UIScrollView) { - // Check if content is still scrollable enough for immersion mode - if immersionModeEnabled && !isContentScrollableEnoughForImmersion { - // Reset bars to visible if content becomes too short - immersionProgress = 0 + if immersiveModeEnabled && !isContentScrollableEnoughForImmersive { + immersiveProgress = 0 } } @@ -234,9 +219,8 @@ final class ImmersionModeManager: NSObject { func handleScrollViewWillBeginDragging(_ scrollView: UIScrollView) { lastScrollOffset = scrollView.contentOffset.y - // On first drag, ensure bars start visible if at top - if immersionModeEnabled && scrollView.contentOffset.y < 20 { - immersionProgress = 0 + if immersiveModeEnabled && scrollView.contentOffset.y < Self.topProximityThreshold { + immersiveProgress = 0 } } @@ -247,16 +231,12 @@ final class ImmersionModeManager: NSObject { targetContentOffset: UnsafeMutablePointer, isRefreshControlArmedOrTriggered: Bool ) { - // Optional: Snap to complete if very close to fully shown/hidden - if immersionModeEnabled && !isRefreshControlArmedOrTriggered { - if immersionProgress > 0.9 { - // Snap to fully hidden - immersionProgress = 1.0 - } else if immersionProgress < 0.1 { - // Snap to fully visible - immersionProgress = 0.0 + if immersiveModeEnabled && !isRefreshControlArmedOrTriggered { + if immersiveProgress > Self.snapToHiddenThreshold { + immersiveProgress = 1.0 + } else if immersiveProgress < Self.snapToVisibleThreshold { + immersiveProgress = 0.0 } - // Otherwise leave at current position } } @@ -267,21 +247,7 @@ final class ImmersionModeManager: NSObject { isRefreshControlArmedOrTriggered: Bool ) { guard !willDecelerate else { return } - - // Handle immersion mode completion when drag ends - if immersionModeEnabled && !isRefreshControlArmedOrTriggered && isContentScrollableEnoughForImmersion { - // Check if we're at the very bottom - let contentHeight = scrollView.contentSize.height - let adjustedBottom = scrollView.adjustedContentInset.bottom - let maxOffsetY = max(contentHeight, scrollView.bounds.height - adjustedBottom) - scrollView.bounds.height + adjustedBottom - let distanceFromBottom = maxOffsetY - scrollView.contentOffset.y - - // Only snap to fully visible if we're at the very bottom AND bars are almost visible - // This prevents jarring snaps and lets the progressive reveal complete naturally - if distanceFromBottom <= 5 && immersionProgress < 0.15 { - immersionProgress = 0 - } - } + snapToVisibleIfAtBottom(scrollView, isRefreshControlArmedOrTriggered: isRefreshControlArmedOrTriggered) } /// Handle scroll view did end decelerating @@ -289,31 +255,17 @@ final class ImmersionModeManager: NSObject { _ scrollView: UIScrollView, isRefreshControlArmedOrTriggered: Bool ) { - // Handle immersion mode completion when deceleration ends - if immersionModeEnabled && !isRefreshControlArmedOrTriggered && isContentScrollableEnoughForImmersion { - // Check if we're at the very bottom - let contentHeight = scrollView.contentSize.height - let adjustedBottom = scrollView.adjustedContentInset.bottom - let maxOffsetY = max(contentHeight, scrollView.bounds.height - adjustedBottom) - scrollView.bounds.height + adjustedBottom - let distanceFromBottom = maxOffsetY - scrollView.contentOffset.y - - // Only snap to fully visible if we're at the very bottom AND bars are almost visible - // This prevents jarring snaps and lets the progressive reveal complete naturally - if distanceFromBottom <= 5 && immersionProgress < 0.15 { - immersionProgress = 0 - } - } + snapToVisibleIfAtBottom(scrollView, isRefreshControlArmedOrTriggered: isRefreshControlArmedOrTriggered) } - /// Main scroll handling logic for immersion mode + /// Main scroll handling logic for immersive mode func handleScrollViewDidScroll( _ scrollView: UIScrollView, isDragging: Bool, isDecelerating: Bool, isRefreshControlArmedOrTriggered: Bool ) { - // Handle immersion mode drawer-style behavior - guard immersionModeEnabled, + guard immersiveModeEnabled, !UIAccessibility.isVoiceOverRunning, (isDragging || isDecelerating), !isRefreshControlArmedOrTriggered else { return } @@ -321,46 +273,34 @@ final class ImmersionModeManager: NSObject { let currentOffset = scrollView.contentOffset.y let scrollDelta = currentOffset - lastScrollOffset - // Only proceed if content is scrollable enough for immersion mode - guard isContentScrollableEnoughForImmersion else { - // Force bars visible if content is too short - immersionProgress = 0 + guard isContentScrollableEnoughForImmersive else { + immersiveProgress = 0 lastScrollOffset = currentOffset return } - // Dead zone at top to prevent jitter - if currentOffset < 20 { - // When very close to top, force bars to be fully visible - immersionProgress = 0 + if currentOffset < Self.topProximityThreshold { + immersiveProgress = 0 lastScrollOffset = currentOffset return } - // Minimum scroll delta threshold to prevent micro-movement responses - guard abs(scrollDelta) > 0.5 else { - // Ignore tiny movements that cause jitter + guard abs(scrollDelta) > Self.minScrollDelta else { return } - let contentHeight = scrollView.contentSize.height - let adjustedBottom = scrollView.adjustedContentInset.bottom - let maxOffsetY = max(contentHeight, scrollView.bounds.height - adjustedBottom) - scrollView.bounds.height + adjustedBottom - + let distanceFromBottom = calculateDistanceFromBottom(scrollView) let barTravelDistance = totalBarTravelDistance - let distanceFromBottom = maxOffsetY - currentOffset - let nearBottomThreshold = barTravelDistance * 2.0 + let nearBottomThreshold = barTravelDistance * Self.progressiveRevealMultiplier let isNearBottom = distanceFromBottom <= nearBottomThreshold if isNearBottom && scrollDelta > 0 { - // Scrolling down toward bottom - progressively reveal bars let targetProgress = (distanceFromBottom / nearBottomThreshold).clamp(0...1) - let incrementalProgress = immersionProgress + (scrollDelta / barTravelDistance) - immersionProgress = min(incrementalProgress, targetProgress).clamp(0...1) + let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) + immersiveProgress = min(incrementalProgress, targetProgress).clamp(0...1) } else { - // Normal 1:1 scroll response - let incrementalProgress = immersionProgress + (scrollDelta / barTravelDistance) - immersionProgress = incrementalProgress.clamp(0...1) + let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) + immersiveProgress = incrementalProgress.clamp(0...1) } lastScrollOffset = currentOffset @@ -368,7 +308,7 @@ final class ImmersionModeManager: NSObject { // MARK: - Private Methods - private func updateBarsForImmersionProgress() { + private func updateBarsForImmersiveProgress() { guard !isUpdatingBars else { return } isUpdatingBars = true defer { isUpdatingBars = false } @@ -376,21 +316,21 @@ final class ImmersionModeManager: NSObject { CATransaction.begin() CATransaction.setDisableActions(true) - guard immersionModeEnabled && immersionProgress > 0 else { + guard immersiveModeEnabled && immersiveProgress > 0 else { safeAreaGradientView.alpha = 0.0 resetAllTransforms() CATransaction.commit() return } - safeAreaGradientView.alpha = immersionProgress + safeAreaGradientView.alpha = immersiveProgress var navBarTransform: CGFloat = 0 if let navBar = findNavigationBar() { let navBarHeight = navBar.bounds.height let deviceSafeAreaTop = postsView?.window?.safeAreaInsets.top ?? 44 - let totalUpwardDistance = navBarHeight + deviceSafeAreaTop + 30 - navBarTransform = -totalUpwardDistance * immersionProgress + let totalUpwardDistance = navBarHeight + deviceSafeAreaTop + Self.bottomProximityDistance + navBarTransform = -totalUpwardDistance * immersiveProgress navBar.transform = CGAffineTransform(translationX: 0, y: navBarTransform) } @@ -400,13 +340,12 @@ final class ImmersionModeManager: NSObject { let toolbarHeight = toolbar.bounds.height let deviceSafeAreaBottom = postsView?.window?.safeAreaInsets.bottom ?? 34 let totalDownwardDistance = toolbarHeight + deviceSafeAreaBottom - toolbar.transform = CGAffineTransform(translationX: 0, y: totalDownwardDistance * immersionProgress) + toolbar.transform = CGAffineTransform(translationX: 0, y: totalDownwardDistance * immersiveProgress) } CATransaction.commit() } - /// Reset all transforms to identity private func resetAllTransforms() { if let foundNavBar = findNavigationBar() { foundNavBar.transform = .identity @@ -415,8 +354,21 @@ final class ImmersionModeManager: NSObject { toolbar?.transform = .identity } - private func updateScrollViewInsetsIfNeeded() { - postsView?.updateScrollViewInsets() + private func calculateDistanceFromBottom(_ scrollView: UIScrollView) -> CGFloat { + let contentHeight = scrollView.contentSize.height + let adjustedBottom = scrollView.adjustedContentInset.bottom + let maxOffsetY = max(contentHeight, scrollView.bounds.height - adjustedBottom) - scrollView.bounds.height + adjustedBottom + return maxOffsetY - scrollView.contentOffset.y + } + + private func snapToVisibleIfAtBottom(_ scrollView: UIScrollView, isRefreshControlArmedOrTriggered: Bool) { + if immersiveModeEnabled && !isRefreshControlArmedOrTriggered && isContentScrollableEnoughForImmersive { + let distanceFromBottom = calculateDistanceFromBottom(scrollView) + + if distanceFromBottom <= Self.bottomDistanceThreshold && immersiveProgress < Self.bottomSnapThreshold { + immersiveProgress = 0 + } + } } private func findNavigationBar() -> UINavigationBar? { @@ -450,11 +402,6 @@ final class ImmersionModeManager: NSObject { return nil } - - /// Clear cached navigation bar when view hierarchy changes - func clearNavigationBarCache() { - cachedNavigationBar = nil - } } // MARK: - Helper Extensions diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index d20a45001..400eda2a5 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -21,6 +21,9 @@ final class NavigationController: UINavigationController, Themeable { static let fullyScrolled: CGFloat = 0.99 } + private static let gradientImageAlpha: CGFloat = 2.0 + private static let gradientImageSize = CGSize(width: 1, height: 96) + fileprivate weak var realDelegate: UINavigationControllerDelegate? fileprivate lazy var unpopHandler: UnpoppingViewHandler? = { guard UIDevice.current.userInterfaceIdiom == .phone else { return nil } @@ -54,7 +57,7 @@ final class NavigationController: UINavigationController, Themeable { } @available(iOS 26.0, *) - private func createGradientBackgroundImage(from color: UIColor, size: CGSize = CGSize(width: 1, height: 96)) -> UIImage? { + private func createGradientBackgroundImage(from color: UIColor, size: CGSize = gradientImageSize) -> UIImage? { let format = UIGraphicsImageRendererFormat() format.opaque = false let renderer = UIGraphicsImageRenderer(size: size, format: format) @@ -63,14 +66,14 @@ final class NavigationController: UINavigationController, Themeable { let colorSpace = CGColorSpaceCreateDeviceRGB() let colors = [color.cgColor, color.withAlphaComponent(0.0).cgColor] as CFArray let locations: [CGFloat] = [0.0, 1.0] - + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: locations) else { return } - + let startPoint = CGPoint(x: 0, y: 0) let endPoint = CGPoint(x: 0, y: size.height) - + context.cgContext.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: []) } } @@ -222,6 +225,17 @@ final class NavigationController: UINavigationController, Themeable { private func updateNavigationBarBackgroundWithProgress(_ progress: CGFloat) { let appearance = UINavigationBarAppearance() + configureBackground(for: appearance, progress: progress) + configureBackIndicator(for: appearance, progress: progress) + configureTitleAndButtons(for: appearance, progress: progress) + applyAppearance(appearance, progress: progress) + } + + @available(iOS 26.0, *) + private func configureBackground(for appearance: UINavigationBarAppearance, progress: CGFloat) { + appearance.shadowColor = nil + appearance.shadowImage = nil + if progress < ScrollProgress.atTop { appearance.configureWithOpaqueBackground() appearance.backgroundColor = theme["navigationBarTintColor"] @@ -232,8 +246,10 @@ final class NavigationController: UINavigationController, Themeable { } else { appearance.configureWithTransparentBackground() - let opaqueColor: UIColor = theme["navigationBarTintColor"]! - let gradientBaseColor: UIColor = theme["listHeaderBackgroundColor"]! + guard let opaqueColor = theme[uicolor: "navigationBarTintColor"], + let gradientBaseColor = theme[uicolor: "listHeaderBackgroundColor"] else { + return + } if let gradientImage = createGradientBackgroundImage(from: gradientBaseColor) { appearance.backgroundImage = gradientImage @@ -243,10 +259,10 @@ final class NavigationController: UINavigationController, Themeable { appearance.backgroundColor = interpolateColor(from: opaqueColor, to: gradientBaseColor, progress: progress) } } + } - appearance.shadowColor = nil - appearance.shadowImage = nil - + @available(iOS 26.0, *) + private func configureBackIndicator(for appearance: UINavigationBarAppearance, progress: CGFloat) { if progress > ScrollProgress.fullyScrolled { if let backImage = UIImage(named: "back")?.withTintColor(.label, renderingMode: .alwaysOriginal) { appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) @@ -256,37 +272,28 @@ final class NavigationController: UINavigationController, Themeable { appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } } + } - if progress < ScrollProgress.atTop { - let textColor: UIColor = theme["navigationBarTextColor"]! - - appearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) - ] - let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) - configureButtonAppearance(appearance, font: buttonFont) + @available(iOS 26.0, *) + private func configureTitleAndButtons(for appearance: UINavigationBarAppearance, progress: CGFloat) { + let textColor: UIColor + if progress > ScrollProgress.fullyScrolled { + textColor = theme["mode"] == "dark" ? .white : .black + } else { + textColor = theme[uicolor: "navigationBarTextColor"] ?? .label } - else if progress > ScrollProgress.fullyScrolled { - let textColor: UIColor = theme["mode"] == "dark" ? .white : .black - appearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) - ] - let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) - configureButtonAppearance(appearance, font: buttonFont) - } else { - let textColor: UIColor = theme["navigationBarTextColor"]! + appearance.titleTextAttributes = [ + .foregroundColor: textColor, + .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] - appearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) - ] - let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) - configureButtonAppearance(appearance, font: buttonFont) - } + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + configureButtonAppearance(appearance, font: buttonFont) + } + @available(iOS 26.0, *) + private func applyAppearance(_ appearance: UINavigationBarAppearance, progress: CGFloat) { awfulNavigationBar.standardAppearance = appearance awfulNavigationBar.scrollEdgeAppearance = appearance awfulNavigationBar.compactAppearance = appearance @@ -349,15 +356,15 @@ final class NavigationController: UINavigationController, Themeable { initialAppearance.shadowColor = nil initialAppearance.shadowImage = nil - let textColor: UIColor = theme["navigationBarTextColor"]! + let textColor = theme[uicolor: "navigationBarTextColor"] ?? .label if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { initialAppearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } initialAppearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + .foregroundColor: textColor, + .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) ] let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) configureButtonAppearance(initialAppearance, font: buttonFont) @@ -383,9 +390,9 @@ final class NavigationController: UINavigationController, Themeable { appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } - let textColor: UIColor = theme["navigationBarTextColor"]! - appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold)] + let textColor = theme[uicolor: "navigationBarTextColor"] ?? .label + appearance.titleTextAttributes = [.foregroundColor: textColor, + .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold)] let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) configureButtonAppearance(appearance, font: buttonFont) @@ -401,7 +408,7 @@ final class NavigationController: UINavigationController, Themeable { awfulNavigationBar.layoutIfNeeded() } } else { - let fallbackTextColor = theme[uicolor: "navigationBarTextColor"]! + guard let fallbackTextColor = theme[uicolor: "navigationBarTextColor"] else { return } let attrs: [NSAttributedString.Key: Any] = [ .foregroundColor: fallbackTextColor, .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) @@ -496,11 +503,10 @@ extension NavigationController: UINavigationControllerDelegate { } if !isScrolledFromTop { - let textColor: UIColor = vcTheme["navigationBarTextColor"]! + guard let textColor = vcTheme[uicolor: "navigationBarTextColor"] else { return } awfulNavigationBar.tintColor = textColor - // Only set explicit bar button item colors on iOS < 26 if #unavailable(iOS 26.0) { viewController.navigationItem.leftBarButtonItem?.tintColor = textColor viewController.navigationItem.rightBarButtonItem?.tintColor = textColor diff --git a/App/View Controllers/Posts/PostsPageSettingsViewController.swift b/App/View Controllers/Posts/PostsPageSettingsViewController.swift index 6a5894c52..14702e780 100644 --- a/App/View Controllers/Posts/PostsPageSettingsViewController.swift +++ b/App/View Controllers/Posts/PostsPageSettingsViewController.swift @@ -16,7 +16,7 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati @FoilDefaultStorage(Settings.darkMode) private var darkMode @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics @FoilDefaultStorage(Settings.fontScale) private var fontScale - @FoilDefaultStorage(Settings.immersionModeEnabled) private var immersionModeEnabled + @FoilDefaultStorage(Settings.immersiveModeEnabled) private var immersiveModeEnabled @FoilDefaultStorage(Settings.showAvatars) private var showAvatars @FoilDefaultStorage(Settings.loadImages) private var showImages @@ -75,15 +75,15 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati darkMode = sender.isOn } - private var immersionModeStack: UIStackView? - private var immersionModeLabel: UILabel? - private var immersionModeSwitch: UISwitch? + private var immersiveModeStack: UIStackView? + private var immersiveModeLabel: UILabel? + private var immersiveModeSwitch: UISwitch? - @objc private func toggleImmersionMode(_ sender: UISwitch) { + @objc private func toggleImmersiveMode(_ sender: UISwitch) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - immersionModeEnabled = sender.isOn + immersiveModeEnabled = sender.isOn } private lazy var fontScaleFormatter: NumberFormatter = { @@ -143,13 +143,13 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati .sink { [weak self] in self?.imagesSwitch?.isOn = $0 } .store(in: &cancellables) - $immersionModeEnabled + $immersiveModeEnabled .receive(on: RunLoop.main) - .sink { [weak self] in self?.immersionModeSwitch?.isOn = $0 } + .sink { [weak self] in self?.immersiveModeSwitch?.isOn = $0 } .store(in: &cancellables) DispatchQueue.main.async { [weak self] in - self?.setupImmersionModeUI() + self?.setupImmersiveModeUI() } } @@ -158,27 +158,27 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati updatePreferredContentSize() } - private func setupImmersionModeUI() { - guard isViewLoaded, immersionModeStack == nil else { return } + private func setupImmersiveModeUI() { + guard isViewLoaded, immersiveModeStack == nil else { return } let label = UILabel() - label.text = "Immersion Mode" + label.text = "Immersive Mode" label.font = UIFont.preferredFont(forTextStyle: .body) label.textColor = theme["sheetTextColor"] ?? UIColor.label - immersionModeLabel = label + immersiveModeLabel = label let modeSwitch = UISwitch() - modeSwitch.isOn = immersionModeEnabled + modeSwitch.isOn = immersiveModeEnabled modeSwitch.onTintColor = theme["settingsSwitchColor"] - modeSwitch.addTarget(self, action: #selector(toggleImmersionMode(_:)), for: .valueChanged) - immersionModeSwitch = modeSwitch + modeSwitch.addTarget(self, action: #selector(toggleImmersiveMode(_:)), for: .valueChanged) + immersiveModeSwitch = modeSwitch let stack = UIStackView(arrangedSubviews: [label, modeSwitch]) stack.axis = .horizontal stack.distribution = .equalSpacing stack.alignment = .center stack.translatesAutoresizingMaskIntoConstraints = false - immersionModeStack = stack + immersiveModeStack = stack if let darkModeStack = darkModeStack, let parentStack = darkModeStack.superview as? UIStackView { @@ -218,8 +218,8 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati uiswitch.onTintColor = theme["settingsSwitchColor"] } - immersionModeLabel?.textColor = theme["sheetTextColor"] ?? UIColor.label - immersionModeSwitch?.onTintColor = theme["settingsSwitchColor"] + immersiveModeLabel?.textColor = theme["sheetTextColor"] ?? UIColor.label + immersiveModeSwitch?.onTintColor = theme["settingsSwitchColor"] } // MARK: UIAdaptivePresentationControllerDelegate diff --git a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift index bc8e2ffc9..a40ab3899 100644 --- a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift +++ b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift @@ -174,9 +174,7 @@ final class PostsPageTopBarLiquidGlass: UIView, PostsPageTopBarProtocol { var scrollToEnd: (() -> Void)? { didSet { updateButtonsEnabled() } } - - // MARK: Gunk - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index 81fdbd770..60281861b 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -21,7 +21,7 @@ final class PostsPageView: UIView { @FoilDefaultStorage(Settings.frogAndGhostEnabled) private var frogAndGhostEnabled var viewHasBeenScrolledOnce: Bool = false - let immersionModeManager = ImmersionModeManager() + let immersiveModeManager = ImmersiveModeManager() // MARK: Loading view @@ -235,7 +235,7 @@ final class PostsPageView: UIView { let toolbar = Toolbar(frame: CGRect(x: 0, y: 0, width: 320, height: 44) /* somewhat arbitrary size to avoid unhelpful unsatisfiable constraints console messages */) private var safeAreaGradientView: GradientView { - return immersionModeManager.safeAreaGradientView + return immersiveModeManager.safeAreaGradientView } private lazy var fallbackSafeAreaGradientView: GradientView = { @@ -269,7 +269,7 @@ final class PostsPageView: UIView { addSubview(renderView) if #available(iOS 26.0, *) { - addSubview(immersionModeManager.safeAreaGradientView) + addSubview(immersiveModeManager.safeAreaGradientView) } else { addSubview(fallbackSafeAreaGradientView) } @@ -278,7 +278,7 @@ final class PostsPageView: UIView { addSubview(toolbar) renderView.scrollView.addSubview(refreshControlContainer) - immersionModeManager.configure( + immersiveModeManager.configure( postsView: self, navigationController: nil, // Will be set from PostsPageViewController renderView: renderView, @@ -299,7 +299,7 @@ final class PostsPageView: UIView { loadingViewContainer.frame = bounds if #available(iOS 26.0, *) { - immersionModeManager.updateGradientLayout(in: self) + immersiveModeManager.updateGradientLayout(in: self) } if toolbar.transform == .identity { @@ -322,11 +322,11 @@ final class PostsPageView: UIView { let topBarHeight = topBarContainer.layoutFittingCompressedHeight(targetWidth: bounds.width) - // Position topBarContainer based on immersion mode state + // Position topBarContainer based on immersive mode state let topBarY: CGFloat - if immersionModeManager.shouldPositionTopBarForImmersion() { - // In immersion mode, position to attach directly to navigation bar - topBarY = immersionModeManager.calculateTopBarY(normalY: bounds.minY + layoutMargins.top) + if immersiveModeManager.shouldPositionTopBarForImmersive() { + // In immersive mode, position to attach directly to navigation bar + topBarY = immersiveModeManager.calculateTopBarY(normalY: bounds.minY + layoutMargins.top) } else { // Normal positioning topBarY = bounds.minY + layoutMargins.top @@ -339,18 +339,18 @@ final class PostsPageView: UIView { height: topBarHeight) updateTopBarContainerFrameAndScrollViewInsets() - immersionModeManager.reapplyTransformsAfterLayout() + immersiveModeManager.reapplyTransformsAfterLayout() } /// Assumes that various views (top bar container, refresh control container, toolbar) have been laid out. func updateScrollViewInsets() { let scrollView = renderView.scrollView - // Calculate bottom inset based on immersion mode state + // Calculate bottom inset based on immersive mode state let bottomInset: CGFloat - if immersionModeManager.shouldAdjustScrollInsets() { + if immersiveModeManager.shouldAdjustScrollInsets() { let normalInset = bounds.maxY - toolbar.frame.minY - bottomInset = immersionModeManager.calculateBottomInset(normalBottomInset: normalInset) + bottomInset = immersiveModeManager.calculateBottomInset(normalBottomInset: normalInset) } else { bottomInset = bounds.maxY - toolbar.frame.minY } @@ -361,10 +361,10 @@ final class PostsPageView: UIView { } scrollView.contentInset = contentInset - // Calculate indicator bottom inset based on immersion mode state + // Calculate indicator bottom inset based on immersive mode state let indicatorBottomInset: CGFloat - if immersionModeManager.shouldAdjustScrollInsets() { - indicatorBottomInset = immersionModeManager.calculateBottomInset(normalBottomInset: bounds.maxY - toolbar.frame.minY) + if immersiveModeManager.shouldAdjustScrollInsets() { + indicatorBottomInset = immersiveModeManager.calculateBottomInset(normalBottomInset: bounds.maxY - toolbar.frame.minY) } else { indicatorBottomInset = bounds.maxY - toolbar.frame.minY } @@ -620,7 +620,7 @@ extension PostsPageView: ScrollViewDelegateExtras { func scrollViewDidChangeContentSize(_ scrollView: UIScrollView) { setNeedsLayout() - immersionModeManager.handleScrollViewDidChangeContentSize(scrollView) + immersiveModeManager.handleScrollViewDidChangeContentSize(scrollView) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { @@ -631,13 +631,13 @@ extension PostsPageView: ScrollViewDelegateExtras { viewHasBeenScrolledOnce = true } - // Delegate immersion mode handling to manager - immersionModeManager.handleScrollViewWillBeginDragging(scrollView) + // Delegate immersive mode handling to manager + immersiveModeManager.handleScrollViewWillBeginDragging(scrollView) } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { - immersionModeManager.handleScrollViewWillEndDragging( + immersiveModeManager.handleScrollViewWillEndDragging( scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset, @@ -650,8 +650,8 @@ extension PostsPageView: ScrollViewDelegateExtras { break case .ready, .awaitingScrollEnd, .refreshing, .disabled: - // Only handle top bar if immersion mode is disabled - if !immersionModeManager.shouldPositionTopBarForImmersion() { + // Only handle top bar if immersive mode is disabled + if !immersiveModeManager.shouldPositionTopBarForImmersive() { switch topBarState { case .hidden where velocity.y < 0: topBarState = .appearing(fromContentOffset: scrollView.contentOffset) @@ -684,7 +684,7 @@ extension PostsPageView: ScrollViewDelegateExtras { if !willDecelerate { updateTopBarDidEndDecelerating() - immersionModeManager.handleScrollViewDidEndDragging( + immersiveModeManager.handleScrollViewDidEndDragging( scrollView, willDecelerate: willDecelerate, isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered @@ -705,7 +705,7 @@ extension PostsPageView: ScrollViewDelegateExtras { updateTopBarDidEndDecelerating() - immersionModeManager.handleScrollViewDidEndDecelerating( + immersiveModeManager.handleScrollViewDidEndDecelerating( scrollView, isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered ) @@ -757,7 +757,7 @@ extension PostsPageView: ScrollViewDelegateExtras { break } - immersionModeManager.handleScrollViewDidScroll( + immersiveModeManager.handleScrollViewDidScroll( scrollView, isDragging: scrollView.isDragging, isDecelerating: scrollView.isDecelerating, diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 303e7db56..41055a813 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -1697,8 +1697,8 @@ final class PostsPageViewController: ViewController { postsView.renderView.scrollView.contentInsetAdjustmentBehavior = .never view.addSubview(postsView, constrainEdges: .all) - // Configure the immersion mode manager with navigation controller - postsView.immersionModeManager.configure( + // Configure the immersive mode manager with navigation controller + postsView.immersiveModeManager.configure( postsView: postsView, navigationController: navigationController, renderView: postsView.renderView, @@ -1861,18 +1861,14 @@ final class PostsPageViewController: ViewController { configureUserActivityIfPossible() } - // MARK: - ImmersiveModeViewController - - func exitImmersionMode() { - postsView.immersionModeManager.exitImmersionMode() + func exitImmersiveMode() { + postsView.immersiveModeManager.exitImmersiveMode() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Reset navigation bar BEFORE the transition starts - // This ensures the bar is visible during swipe-back gestures - exitImmersionMode() + exitImmersiveMode() // Immediately reset navigation bar transform if let navigationBar = navigationController?.navigationBar { @@ -1891,7 +1887,7 @@ final class PostsPageViewController: ViewController { userActivity = nil // Additional cleanup as fallback (in case viewWillDisappear wasn't called) - postsView.immersionModeManager.exitImmersionMode() + postsView.immersiveModeManager.exitImmersiveMode() // Reset navigation bar transform to identity to ensure it's visible if let navigationBar = navigationController?.navigationBar { diff --git a/App/Views/LiquidGlassTitleView.swift b/App/Views/LiquidGlassTitleView.swift index f29d7776d..37af8f95b 100644 --- a/App/Views/LiquidGlassTitleView.swift +++ b/App/Views/LiquidGlassTitleView.swift @@ -7,13 +7,19 @@ import UIKit @available(iOS 26.0, *) final class LiquidGlassTitleView: UIView { + private static let lineSpacing: CGFloat = -2 + private static let horizontalPadding: CGFloat = 16 + private static let verticalPadding: CGFloat = 8 + private static let defaultWidth: CGFloat = 320 + private static let defaultHeight: CGFloat = 56 + private var visualEffectView: UIVisualEffectView = { let effect = UIGlassEffect() let view = UIVisualEffectView(effect: effect) view.translatesAutoresizingMaskIntoConstraints = false return view }() - + private let titleLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false @@ -51,17 +57,17 @@ final class LiquidGlassTitleView: UIView { private func updateTitleDisplay() { guard let text = titleLabel.text, !text.isEmpty else { return } - + let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = .center - paragraphStyle.lineSpacing = -2 + paragraphStyle.lineSpacing = Self.lineSpacing paragraphStyle.lineBreakMode = .byWordWrapping - + let attributes: [NSAttributedString.Key: Any] = [ .paragraphStyle: paragraphStyle, .font: titleLabel.font ?? UIFont.preferredFont(forTextStyle: .callout) ] - + titleLabel.attributedText = NSAttributedString(string: text, attributes: attributes) } @@ -84,11 +90,11 @@ final class LiquidGlassTitleView: UIView { visualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), visualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), visualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), - - titleLabel.topAnchor.constraint(equalTo: visualEffectView.contentView.topAnchor, constant: 8), - titleLabel.leadingAnchor.constraint(equalTo: visualEffectView.contentView.leadingAnchor, constant: 16), - titleLabel.trailingAnchor.constraint(equalTo: visualEffectView.contentView.trailingAnchor, constant: -16), - titleLabel.bottomAnchor.constraint(equalTo: visualEffectView.contentView.bottomAnchor, constant: -8) + + titleLabel.topAnchor.constraint(equalTo: visualEffectView.contentView.topAnchor, constant: Self.verticalPadding), + titleLabel.leadingAnchor.constraint(equalTo: visualEffectView.contentView.leadingAnchor, constant: Self.horizontalPadding), + titleLabel.trailingAnchor.constraint(equalTo: visualEffectView.contentView.trailingAnchor, constant: -Self.horizontalPadding), + titleLabel.bottomAnchor.constraint(equalTo: visualEffectView.contentView.bottomAnchor, constant: -Self.verticalPadding) ]) isAccessibilityElement = false @@ -106,10 +112,10 @@ final class LiquidGlassTitleView: UIView { } override var intrinsicContentSize: CGSize { - return CGSize(width: 320, height: 56) + return CGSize(width: Self.defaultWidth, height: Self.defaultHeight) } - + override func sizeThatFits(_ size: CGSize) -> CGSize { - return CGSize(width: 320, height: 56) + return CGSize(width: Self.defaultWidth, height: Self.defaultHeight) } } diff --git a/App/Views/PageNumberView.swift b/App/Views/PageNumberView.swift index 174d246d3..d0ffb89bf 100644 --- a/App/Views/PageNumberView.swift +++ b/App/Views/PageNumberView.swift @@ -6,6 +6,19 @@ import UIKit final class PageNumberView: UIView { + + private static let minWidth: CGFloat = 60 + private static let heightModern: CGFloat = 39 // iOS 26+ + private static let heightLegacy: CGFloat = 44 // iOS < 26 + + private static var currentHeight: CGFloat { + if #available(iOS 26.0, *) { + return heightModern + } else { + return heightLegacy + } + } + private let pageLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false @@ -27,9 +40,7 @@ final class PageNumberView: UIView { } } - var textColor: UIColor = { - return .label - }() { + var textColor: UIColor = .label { didSet { updateColors() } @@ -60,14 +71,8 @@ final class PageNumberView: UIView { pageLabel.centerYAnchor.constraint(equalTo: centerYAnchor), pageLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), pageLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), - widthAnchor.constraint(greaterThanOrEqualToConstant: 60), - heightAnchor.constraint(equalToConstant: { - if #available(iOS 26.0, *) { - return 39 - } else { - return 44 - } - }()) + widthAnchor.constraint(greaterThanOrEqualToConstant: Self.minWidth), + heightAnchor.constraint(equalToConstant: Self.currentHeight) ]) pageLabel.setContentCompressionResistancePriority(.required, for: .horizontal) @@ -113,13 +118,7 @@ final class PageNumberView: UIView { override var intrinsicContentSize: CGSize { let labelSize = pageLabel.intrinsicContentSize - let height: CGFloat - if #available(iOS 26.0, *) { - height = 39 - } else { - height = 44 - } - return CGSize(width: max(labelSize.width, 60), height: height) + return CGSize(width: max(labelSize.width, Self.minWidth), height: Self.currentHeight) } override func layoutSubviews() { diff --git a/AwfulSettings/Sources/AwfulSettings/Settings.swift b/AwfulSettings/Sources/AwfulSettings/Settings.swift index c8d84e36c..95a1442c1 100644 --- a/AwfulSettings/Sources/AwfulSettings/Settings.swift +++ b/AwfulSettings/Sources/AwfulSettings/Settings.swift @@ -71,8 +71,8 @@ public enum Settings { /// Mode for Imgur image uploads (Off, Anonymous, or with Account) public static let imgurUploadMode = Setting(key: "imgur_upload_mode", default: ImgurUploadMode.default) - /// Hide navigation and toolbar when scrolling down in posts view. - public static let immersionModeEnabled = Setting(key: "immersion_mode_enabled", default: false) + /// Enable immersive mode: hides navigation and toolbar when scrolling, reveals when reaching bottom or scrolling up. + public static let immersiveModeEnabled = Setting(key: "immersive_mode_enabled", default: false) /// What percentage to multiply the default post font size by. Stored as percentage points, i.e. default is `100` aka "100% size" aka the default. public static let fontScale = Setting(key: "font_scale", default: 100.0) diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index e4d03e736..098988394 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -118,7 +118,7 @@ "Imgur Uploads" : { }, - "Immersion Mode" : { + "Immersive Mode" : { }, "Links" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index bf9f8daf7..90a80db67 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -24,7 +24,7 @@ public struct SettingsView: View { @AppStorage(Settings.frogAndGhostEnabled) private var frogAndGhostEnabled @AppStorage(Settings.handoffEnabled) private var handoffEnabled @AppStorage(Settings.hideSidebarInLandscape) private var hideSidebarInLandscape - @AppStorage(Settings.immersionModeEnabled) private var immersionModeEnabled + @AppStorage(Settings.immersiveModeEnabled) private var immersiveModeEnabled @AppStorage(Settings.loadImages) private var loadImages @AppStorage(Settings.openTwitterLinksInTwitter) private var openLinksInTwitter @AppStorage(Settings.openYouTubeLinksInYouTube) private var openLinksInYouTube @@ -151,7 +151,7 @@ public struct SettingsView: View { Toggle("Embed Bluesky Posts", bundle: .module, isOn: $embedBlueskyPosts) Toggle("Embed Tweets", bundle: .module, isOn: $embedTweets) Toggle("Double-Tap Post to Jump", bundle: .module, isOn: $doubleTapPostToJump) - Toggle("Immersion Mode", bundle: .module, isOn: $immersionModeEnabled) + Toggle("Immersive Mode", bundle: .module, isOn: $immersiveModeEnabled) Toggle("Enable Haptics", bundle: .module, isOn: $enableHaptics) if isPad { Toggle("Enable Custom Title Post Layout", bundle: .module, isOn: $customTitlePostLayout) From a83ea0500277b2e7b70a5597950107854940e8eb Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:10:26 +1100 Subject: [PATCH 03/11] Refactor 2 --- .../ImmersiveModeViewController.swift | 78 ++++++++++++++----- App/Navigation/NavigationController.swift | 6 +- .../PostsPageSettingsViewController.swift | 36 ++++----- .../Posts/PostsPageView.swift | 33 ++++---- .../Posts/PostsPageViewController.swift | 30 +++---- 5 files changed, 106 insertions(+), 77 deletions(-) diff --git a/App/Navigation/ImmersiveModeViewController.swift b/App/Navigation/ImmersiveModeViewController.swift index bf956639d..6bb855a01 100644 --- a/App/Navigation/ImmersiveModeViewController.swift +++ b/App/Navigation/ImmersiveModeViewController.swift @@ -60,17 +60,21 @@ final class ImmersiveModeManager: NSObject { // MARK: - State Properties /// Progress of immersive mode (0.0 = bars fully visible, 1.0 = bars fully hidden) - private var immersiveProgress: CGFloat = 0.0 { - didSet { + private var _immersiveProgress: CGFloat = 0.0 + private var immersiveProgress: CGFloat { + get { _immersiveProgress } + set { + let clampedValue = newValue.clamp(0...1) + guard immersiveModeEnabled && !UIAccessibility.isVoiceOverRunning else { - immersiveProgress = 0.0 + _immersiveProgress = 0.0 return } - let oldProgress = oldValue - immersiveProgress = immersiveProgress.clamp(0...1) - if oldProgress != immersiveProgress { - updateBarsForImmersiveProgress() - } + + guard _immersiveProgress != clampedValue else { return } + + _immersiveProgress = clampedValue + updateBarsForImmersiveProgress() } } @@ -83,6 +87,9 @@ final class ImmersiveModeManager: NSObject { /// Flag to prevent recursive updates private var isUpdatingBars = false + /// Cached value for total bar travel distance + private var cachedTotalBarTravelDistance: CGFloat? + // MARK: - UI Elements lazy var safeAreaGradientView: GradientView = { @@ -96,6 +103,10 @@ final class ImmersiveModeManager: NSObject { /// Actual distance bars travel when hiding (calculated dynamically based on bar heights) private var totalBarTravelDistance: CGFloat { + if let cached = cachedTotalBarTravelDistance { + return cached + } + guard let postsView = postsView, let window = postsView.window else { return 100 } @@ -107,10 +118,12 @@ final class ImmersiveModeManager: NSObject { let navBarHeight = navBar.bounds.height let deviceSafeAreaTop = window.safeAreaInsets.top let topDistance = navBarHeight + deviceSafeAreaTop + Self.bottomProximityDistance - return max(bottomDistance, topDistance) + cachedTotalBarTravelDistance = max(bottomDistance, topDistance) + } else { + cachedTotalBarTravelDistance = bottomDistance } - return bottomDistance + return cachedTotalBarTravelDistance ?? 100 } /// Check if content is scrollable enough to warrant immersive mode @@ -185,6 +198,9 @@ final class ImmersiveModeManager: NSObject { /// Apply immersive transforms after layout if needed func reapplyTransformsAfterLayout() { + // Invalidate cached distance as layout may have changed bar sizes + cachedTotalBarTravelDistance = nil + if immersiveModeEnabled && immersiveProgress > 0 { updateBarsForImmersiveProgress() } @@ -294,7 +310,11 @@ final class ImmersiveModeManager: NSObject { let nearBottomThreshold = barTravelDistance * Self.progressiveRevealMultiplier let isNearBottom = distanceFromBottom <= nearBottomThreshold + // When approaching the bottom while scrolling down, progressively reveal bars + // so they're fully visible when reaching the end of content. This prevents users + // from being unable to access navigation when at the bottom of the page. if isNearBottom && scrollDelta > 0 { + // Calculate target progress based on proximity to bottom (closer = more revealed) let targetProgress = (distanceFromBottom / nearBottomThreshold).clamp(0...1) let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) immersiveProgress = min(incrementalProgress, targetProgress).clamp(0...1) @@ -325,27 +345,39 @@ final class ImmersiveModeManager: NSObject { safeAreaGradientView.alpha = immersiveProgress - var navBarTransform: CGFloat = 0 + let navBarTransform = calculateNavigationBarTransform() if let navBar = findNavigationBar() { - let navBarHeight = navBar.bounds.height - let deviceSafeAreaTop = postsView?.window?.safeAreaInsets.top ?? 44 - let totalUpwardDistance = navBarHeight + deviceSafeAreaTop + Self.bottomProximityDistance - navBarTransform = -totalUpwardDistance * immersiveProgress navBar.transform = CGAffineTransform(translationX: 0, y: navBarTransform) } topBarContainer?.transform = CGAffineTransform(translationX: 0, y: navBarTransform) if let toolbar = toolbar { - let toolbarHeight = toolbar.bounds.height - let deviceSafeAreaBottom = postsView?.window?.safeAreaInsets.bottom ?? 34 - let totalDownwardDistance = toolbarHeight + deviceSafeAreaBottom - toolbar.transform = CGAffineTransform(translationX: 0, y: totalDownwardDistance * immersiveProgress) + let toolbarTransform = calculateToolbarTransform() + toolbar.transform = CGAffineTransform(translationX: 0, y: toolbarTransform) } CATransaction.commit() } + private func calculateNavigationBarTransform() -> CGFloat { + guard let navBar = findNavigationBar() else { return 0 } + + let navBarHeight = navBar.bounds.height + let deviceSafeAreaTop = postsView?.window?.safeAreaInsets.top ?? 44 + let totalUpwardDistance = navBarHeight + deviceSafeAreaTop + Self.bottomProximityDistance + return -totalUpwardDistance * immersiveProgress + } + + private func calculateToolbarTransform() -> CGFloat { + guard let toolbar = toolbar else { return 0 } + + let toolbarHeight = toolbar.bounds.height + let deviceSafeAreaBottom = postsView?.window?.safeAreaInsets.bottom ?? 34 + let totalDownwardDistance = toolbarHeight + deviceSafeAreaBottom + return totalDownwardDistance * immersiveProgress + } + private func resetAllTransforms() { if let foundNavBar = findNavigationBar() { foundNavBar.transform = .identity @@ -354,6 +386,14 @@ final class ImmersiveModeManager: NSObject { toolbar?.transform = .identity } + /// Calculates the remaining scrollable distance from current position to the bottom of content + /// + /// The calculation accounts for: + /// - Content that is shorter than the scroll view bounds (uses bounds height as minimum) + /// - Bottom content inset (typically the toolbar height) + /// - Current scroll offset + /// + /// - Returns: Distance in points from current scroll position to the effective bottom private func calculateDistanceFromBottom(_ scrollView: UIScrollView) -> CGFloat { let contentHeight = scrollView.contentSize.height let adjustedBottom = scrollView.adjustedContentInset.bottom diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index 400eda2a5..8e0572a43 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -325,15 +325,13 @@ final class NavigationController: UINavigationController, Themeable { private func updateNavigationBarAppearance(with theme: Theme, for viewController: UIViewController? = nil) { awfulNavigationBar.barTintColor = theme["navigationBarTintColor"] - - // iOS 26: Hide bottom border for liquid glass effect, earlier versions show themed border + if #available(iOS 26.0, *) { awfulNavigationBar.bottomBorderColor = .clear } else { awfulNavigationBar.bottomBorderColor = theme["topBarBottomBorderColor"] } - - // iOS 26: Remove shadow for liquid glass effect, earlier versions use themed shadow + if #available(iOS 26.0, *) { awfulNavigationBar.layer.shadowOpacity = 0 awfulNavigationBar.layer.shadowColor = UIColor.clear.cgColor diff --git a/App/View Controllers/Posts/PostsPageSettingsViewController.swift b/App/View Controllers/Posts/PostsPageSettingsViewController.swift index 14702e780..15779e506 100644 --- a/App/View Controllers/Posts/PostsPageSettingsViewController.swift +++ b/App/View Controllers/Posts/PostsPageSettingsViewController.swift @@ -34,34 +34,26 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati @IBOutlet private var avatarsSwitch: UISwitch! @IBAction func toggleAvatars(_ sender: UISwitch) { - if enableHaptics { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } + performHapticFeedback() showAvatars = sender.isOn } @IBOutlet private var imagesSwitch: UISwitch! @IBAction private func toggleImages(_ sender: UISwitch) { - if enableHaptics { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } + performHapticFeedback() showImages = sender.isOn } - + @IBOutlet private var scaleTextLabel: UILabel! @IBOutlet private var scaleTextStepper: UIStepper! @IBAction private func scaleStepperDidChange(_ sender: UIStepper) { - if enableHaptics { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } + performHapticFeedback() fontScale = sender.value } - + @IBOutlet private var automaticDarkModeSwitch: UISwitch! @IBAction func toggleAutomaticDarkMode(_ sender: UISwitch) { - if enableHaptics { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } + performHapticFeedback() automaticDarkTheme = sender.isOn } @@ -69,21 +61,25 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati @IBOutlet private var darkModeLabel: UILabel! @IBOutlet private var darkModeSwitch: UISwitch! @IBAction func toggleDarkMode(_ sender: UISwitch) { - if enableHaptics { - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } + performHapticFeedback() darkMode = sender.isOn } - + private var immersiveModeStack: UIStackView? private var immersiveModeLabel: UILabel? private var immersiveModeSwitch: UISwitch? - + @objc private func toggleImmersiveMode(_ sender: UISwitch) { + performHapticFeedback() + immersiveModeEnabled = sender.isOn + } + + // MARK: - Helper Methods + + private func performHapticFeedback() { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - immersiveModeEnabled = sender.isOn } private lazy var fontScaleFormatter: NumberFormatter = { diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index 60281861b..120daff02 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -347,13 +347,7 @@ final class PostsPageView: UIView { let scrollView = renderView.scrollView // Calculate bottom inset based on immersive mode state - let bottomInset: CGFloat - if immersiveModeManager.shouldAdjustScrollInsets() { - let normalInset = bounds.maxY - toolbar.frame.minY - bottomInset = immersiveModeManager.calculateBottomInset(normalBottomInset: normalInset) - } else { - bottomInset = bounds.maxY - toolbar.frame.minY - } + let bottomInset = calculateBottomInset() var contentInset = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: bottomInset, right: 0) if case .refreshing = refreshControlState { @@ -362,12 +356,7 @@ final class PostsPageView: UIView { scrollView.contentInset = contentInset // Calculate indicator bottom inset based on immersive mode state - let indicatorBottomInset: CGFloat - if immersiveModeManager.shouldAdjustScrollInsets() { - indicatorBottomInset = immersiveModeManager.calculateBottomInset(normalBottomInset: bounds.maxY - toolbar.frame.minY) - } else { - indicatorBottomInset = bounds.maxY - toolbar.frame.minY - } + let indicatorBottomInset = calculateBottomInset() var indicatorInsets = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: indicatorBottomInset, right: 0) // I'm not sure if this is a bug or if I'm misunderstanding something, but as of iOS 12 it seems that the indicator insets have already taken the layout margins into consideration? That's my guess based on observing their positioning when the indicator insets are set to zero. @@ -376,6 +365,16 @@ final class PostsPageView: UIView { scrollView.scrollIndicatorInsets = indicatorInsets } + private func calculateBottomInset() -> CGFloat { + let normalInset = bounds.maxY - toolbar.frame.minY + + if immersiveModeManager.shouldAdjustScrollInsets() { + return immersiveModeManager.calculateBottomInset(normalBottomInset: normalInset) + } else { + return normalInset + } + } + @objc private func voiceOverStatusDidChange(_ notification: Notification) { if UIAccessibility.isVoiceOverRunning { topBarState = .alwaysVisible @@ -726,6 +725,14 @@ extension PostsPageView: ScrollViewDelegateExtras { } func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Guard against concurrent scroll handling + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.scrollViewDidScroll(scrollView) + } + return + } + let info = ScrollViewInfo(refreshControlHeight: refreshControlContainer.bounds.height, scrollView: scrollView) // Update refreshControlState first, then decide if we care about topBarState. diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 41055a813..24e96a653 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -1863,41 +1863,29 @@ final class PostsPageViewController: ViewController { func exitImmersiveMode() { postsView.immersiveModeManager.exitImmersiveMode() + resetNavigationBarState(animated: false) } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - exitImmersiveMode() - - // Immediately reset navigation bar transform + private func resetNavigationBarState(animated: Bool) { + // Reset navigation bar transform to identity if let navigationBar = navigationController?.navigationBar { navigationBar.transform = .identity } // Ensure navigation bar is not hidden if navigationController?.isNavigationBarHidden == true { - navigationController?.setNavigationBarHidden(false, animated: false) + navigationController?.setNavigationBarHidden(false, animated: animated) } } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + exitImmersiveMode() + } + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - userActivity = nil - - // Additional cleanup as fallback (in case viewWillDisappear wasn't called) - postsView.immersiveModeManager.exitImmersiveMode() - - // Reset navigation bar transform to identity to ensure it's visible - if let navigationBar = navigationController?.navigationBar { - navigationBar.transform = .identity - } - - // Also check if navigation bar was hidden via system API - if navigationController?.isNavigationBarHidden == true { - navigationController?.setNavigationBarHidden(false, animated: animated) - } } override func encodeRestorableState(with coder: NSCoder) { From 9e12d57e2231a2d254201267ad0da94a0ca1896a Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:23:47 +1100 Subject: [PATCH 04/11] Refactor 3 --- ...oller.swift => ImmersiveModeManager.swift} | 0 App/Navigation/NavigationController.swift | 10 ++-- .../Posts/PostsPageView.swift | 18 ++++---- .../Posts/PostsPageViewController.swift | 46 +++++++++---------- Awful.xcodeproj/project.pbxproj | 8 ++-- 5 files changed, 41 insertions(+), 41 deletions(-) rename App/Navigation/{ImmersiveModeViewController.swift => ImmersiveModeManager.swift} (100%) diff --git a/App/Navigation/ImmersiveModeViewController.swift b/App/Navigation/ImmersiveModeManager.swift similarity index 100% rename from App/Navigation/ImmersiveModeViewController.swift rename to App/Navigation/ImmersiveModeManager.swift diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index 8e0572a43..dfc394868 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -21,7 +21,6 @@ final class NavigationController: UINavigationController, Themeable { static let fullyScrolled: CGFloat = 0.99 } - private static let gradientImageAlpha: CGFloat = 2.0 private static let gradientImageSize = CGSize(width: 1, height: 96) fileprivate weak var realDelegate: UINavigationControllerDelegate? @@ -312,8 +311,13 @@ final class NavigationController: UINavigationController, Themeable { var startRed: CGFloat = 0, startGreen: CGFloat = 0, startBlue: CGFloat = 0, startAlpha: CGFloat = 0 var endRed: CGFloat = 0, endGreen: CGFloat = 0, endBlue: CGFloat = 0, endAlpha: CGFloat = 0 - startColor.getRed(&startRed, green: &startGreen, blue: &startBlue, alpha: &startAlpha) - endColor.getRed(&endRed, green: &endGreen, blue: &endBlue, alpha: &endAlpha) + // Convert colors to RGB color space if needed and handle failures + guard startColor.getRed(&startRed, green: &startGreen, blue: &startBlue, alpha: &startAlpha), + endColor.getRed(&endRed, green: &endGreen, blue: &endBlue, alpha: &endAlpha) else { + // If color conversion fails (e.g., non-RGB color space), return the end color at full progress + // or start color at zero progress + return progress >= 0.5 ? endColor : startColor + } let red = startRed + (endRed - startRed) * progress let green = startGreen + (endGreen - startGreen) * progress diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index 120daff02..f5093e29f 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -22,6 +22,9 @@ final class PostsPageView: UIView { var viewHasBeenScrolledOnce: Bool = false let immersiveModeManager = ImmersiveModeManager() + + /// Weak reference to the posts page view controller to avoid responder chain traversal + weak var postsPageViewController: PostsPageViewController? // MARK: Loading view @@ -819,16 +822,11 @@ extension PostsPageView: ScrollViewDelegateExtras { progress = distanceFromTop / transitionDistance } - var responder: UIResponder? = self - while responder != nil { - if let viewController = responder as? PostsPageViewController, - let navController = viewController.navigationController as? NavigationController { - navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) - viewController.updateTitleViewTextColorForScrollProgress(progress) - - break - } - responder = responder?.next + // Update navigation bar tint and title view text color based on scroll progress + if let viewController = postsPageViewController, + let navController = viewController.navigationController as? NavigationController { + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) + viewController.updateTitleViewTextColorForScrollProgress(progress) } } } diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 24e96a653..45dd205d9 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -113,6 +113,7 @@ final class PostsPageViewController: ViewController { private lazy var postsView: PostsPageView = { let postsView = PostsPageView() + postsView.postsPageViewController = self postsView.didStartRefreshing = { [weak self] in self?.loadNextPageOrRefresh() } @@ -205,13 +206,7 @@ final class PostsPageViewController: ViewController { let glassView = liquidGlassTitleView glassView?.title = title glassView?.textColor = theme["mode"] == "dark" ? .white : .black - - switch UIDevice.current.userInterfaceIdiom { - case .pad: - glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) - default: - glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) - } + glassView?.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) navigationItem.titleView = glassView configureNavigationBarForLiquidGlass() @@ -1608,29 +1603,20 @@ final class PostsPageViewController: ViewController { let glassView = liquidGlassTitleView // Set both text color and font from theme glassView?.textColor = theme["mode"] == "dark" ? .white : .black - - switch UIDevice.current.userInterfaceIdiom { - case .pad: - glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) - default: - glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) - } + glassView?.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) // Update navigation bar configuration based on new theme configureNavigationBarForLiquidGlass() } else { // Apply theme to regular title label for iOS < 26 navigationItem.titleLabel.textColor = theme["navigationBarTextColor"] - - switch UIDevice.current.userInterfaceIdiom { - case .pad: - navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) - navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! - default: - navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) + navigationItem.titleLabel.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) + + if UIDevice.current.userInterfaceIdiom == .phone { navigationItem.titleLabel.numberOfLines = 2 - navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! } + + navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"] ?? .label } // Update navigation bar button colors (only for iOS < 26) @@ -1797,7 +1783,19 @@ final class PostsPageViewController: ViewController { // See commentary in `viewDidLoad()` about our layout strategy here. tl;dr layout margins were the highest-level approach available on all versions of iOS that Awful supported, so we'll use them exclusively to represent the safe area. Probably not necessary anymore. postsView.layoutMargins = view.safeAreaInsets } - + + /// Safely retrieves font configuration from the theme with fallback defaults + private func fontForPostTitle(from theme: Theme, idiom: UIUserInterfaceIdiom) -> UIFont { + let sizeAdjustmentKey = idiom == .pad ? "postTitleFontSizeAdjustmentPad" : "postTitleFontSizeAdjustmentPhone" + let weightKey = idiom == .pad ? "postTitleFontWeightPad" : "postTitleFontWeightPhone" + + let sizeAdjustment = theme[double: sizeAdjustmentKey] ?? (idiom == .pad ? 0 : -1) + let weightString = theme[weightKey] ?? "semibold" + let weight = FontWeight(rawValue: weightString)?.weight ?? .semibold + + return UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: sizeAdjustment, weight: weight) + } + @available(iOS 26.0, *) private func configureNavigationBarForLiquidGlass() { guard let navigationBar = navigationController?.navigationBar else { return } @@ -1814,7 +1812,7 @@ final class PostsPageViewController: ViewController { appearance.shadowColor = nil appearance.shadowImage = nil - let textColor: UIColor = theme["navigationBarTextColor"]! + let textColor: UIColor = theme["navigationBarTextColor"] ?? .label appearance.titleTextAttributes = [ NSAttributedString.Key.foregroundColor: textColor, NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index 682d6bbe8..0b47ef71c 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -202,7 +202,7 @@ 2D265F8C292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */; }; 2D265F8F292CB447001336ED /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 2D265F8E292CB447001336ED /* Lottie */; }; 2D327DD627F468CE00D21AB0 /* BookmarkColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */; }; - 2D571B492EC8765F0026826C /* ImmersiveModeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B482EC876590026826C /* ImmersiveModeViewController.swift */; }; + 2D571B492EC8765F0026826C /* ImmersiveModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B482EC876590026826C /* ImmersiveModeManager.swift */; }; 2D571B4B2EC876A20026826C /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4A2EC8769E0026826C /* GradientView.swift */; }; 2D571B4D2EC878230026826C /* PostsPageTopBarProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4C2EC8781E0026826C /* PostsPageTopBarProtocol.swift */; }; 2D571B4F2EC878A70026826C /* PostsPageTopBarLiquidGlass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */; }; @@ -523,7 +523,7 @@ 2D19BA3829C33302009DD94F /* toot60.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = toot60.json; sourceTree = ""; }; 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetOutFrogRefreshSpinnerView.swift; sourceTree = ""; }; 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkColorPicker.swift; sourceTree = ""; }; - 2D571B482EC876590026826C /* ImmersiveModeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveModeViewController.swift; sourceTree = ""; }; + 2D571B482EC876590026826C /* ImmersiveModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveModeManager.swift; sourceTree = ""; }; 2D571B4A2EC8769E0026826C /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; 2D571B4C2EC8781E0026826C /* PostsPageTopBarProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsPageTopBarProtocol.swift; sourceTree = ""; }; 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsPageTopBarLiquidGlass.swift; sourceTree = ""; }; @@ -662,7 +662,7 @@ 1C00E0AB173A006B008895E7 /* Navigation */ = { isa = PBXGroup; children = ( - 2D571B482EC876590026826C /* ImmersiveModeViewController.swift */, + 2D571B482EC876590026826C /* ImmersiveModeManager.swift */, 1C0D80011CF9FE79003EE2D1 /* NavigationBar.swift */, 1C0D80031CF9FE81003EE2D1 /* NavigationController.swift */, 1C0D7FFF1CF9FE70003EE2D1 /* Toolbar.swift */, @@ -1618,7 +1618,7 @@ 1CC256B11A38526B003FA7A8 /* MessageListViewController.swift in Sources */, 2D571B4B2EC876A20026826C /* GradientView.swift in Sources */, 1CC065F72D67028F002BB6A0 /* AppIconImageNames.swift in Sources */, - 2D571B492EC8765F0026826C /* ImmersiveModeViewController.swift in Sources */, + 2D571B492EC8765F0026826C /* ImmersiveModeManager.swift in Sources */, 1C0D80001CF9FE70003EE2D1 /* Toolbar.swift in Sources */, 1C16FC101CC6F19700C88BD1 /* ThreadPreviewViewController.swift in Sources */, 1C09BFF01A09D485007C11F5 /* InAppActionCollectionViewLayout.swift in Sources */, From 1aa30e46edcebd35233588dbe0160969f34b4074 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:37:01 +1100 Subject: [PATCH 05/11] Refactor 4 --- App/Navigation/ImmersiveModeManager.swift | 29 +++------ .../Posts/PostsPageView.swift | 61 +++++++++---------- .../Posts/PostsPageViewController.swift | 20 +++--- App/Views/PageNumberView.swift | 4 +- 4 files changed, 49 insertions(+), 65 deletions(-) diff --git a/App/Navigation/ImmersiveModeManager.swift b/App/Navigation/ImmersiveModeManager.swift index 6bb855a01..4f2bb70b2 100644 --- a/App/Navigation/ImmersiveModeManager.swift +++ b/App/Navigation/ImmersiveModeManager.swift @@ -1,4 +1,4 @@ -// ImmersiveModeViewController.swift +// ImmersiveModeManager.swift // // Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app @@ -10,6 +10,7 @@ import AwfulSettings /// Manages immersive mode behavior for posts view /// Handles hiding/showing navigation bars and toolbars with scroll gestures +@MainActor final class ImmersiveModeManager: NSObject { // MARK: - Constants @@ -25,19 +26,10 @@ final class ImmersiveModeManager: NSObject { // MARK: - Dependencies - /// The posts view that contains the bars to be transformed weak var postsView: PostsPageView? - - /// The navigation controller for accessing the navigation bar weak var navigationController: UINavigationController? - - /// The render view containing the scroll view weak var renderView: RenderView? - - /// The toolbar to be transformed weak var toolbar: UIToolbar? - - /// The top bar container to be transformed weak var topBarContainer: UIView? // MARK: - Configuration @@ -59,7 +51,7 @@ final class ImmersiveModeManager: NSObject { // MARK: - State Properties - /// Progress of immersive mode (0.0 = bars fully visible, 1.0 = bars fully hidden) + /// Immersive mode progress (0.0 = bars fully visible, 1.0 = bars fully hidden) private var _immersiveProgress: CGFloat = 0.0 private var immersiveProgress: CGFloat { get { _immersiveProgress } @@ -78,16 +70,9 @@ final class ImmersiveModeManager: NSObject { } } - /// Last scroll offset to calculate delta private var lastScrollOffset: CGFloat = 0 - - /// Cached navigation bar reference for performance private weak var cachedNavigationBar: UINavigationBar? - - /// Flag to prevent recursive updates private var isUpdatingBars = false - - /// Cached value for total bar travel distance private var cachedTotalBarTravelDistance: CGFloat? // MARK: - UI Elements @@ -148,8 +133,6 @@ final class ImmersiveModeManager: NSObject { self.renderView = renderView self.toolbar = toolbar self.topBarContainer = topBarContainer - - // Clear cached navigation bar when configuration changes cachedNavigationBar = nil } @@ -310,16 +293,18 @@ final class ImmersiveModeManager: NSObject { let nearBottomThreshold = barTravelDistance * Self.progressiveRevealMultiplier let isNearBottom = distanceFromBottom <= nearBottomThreshold + let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) + // When approaching the bottom while scrolling down, progressively reveal bars // so they're fully visible when reaching the end of content. This prevents users // from being unable to access navigation when at the bottom of the page. if isNearBottom && scrollDelta > 0 { // Calculate target progress based on proximity to bottom (closer = more revealed) let targetProgress = (distanceFromBottom / nearBottomThreshold).clamp(0...1) - let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) + // Use min() to cap the reveal rate - can't reveal faster than natural scroll progression + // but can slow down reveal as we approach the bottom (targetProgress becomes constraint) immersiveProgress = min(incrementalProgress, targetProgress).clamp(0...1) } else { - let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) immersiveProgress = incrementalProgress.clamp(0...1) } diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index f5093e29f..57731034a 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -325,15 +325,10 @@ final class PostsPageView: UIView { let topBarHeight = topBarContainer.layoutFittingCompressedHeight(targetWidth: bounds.width) - // Position topBarContainer based on immersive mode state - let topBarY: CGFloat - if immersiveModeManager.shouldPositionTopBarForImmersive() { - // In immersive mode, position to attach directly to navigation bar - topBarY = immersiveModeManager.calculateTopBarY(normalY: bounds.minY + layoutMargins.top) - } else { - // Normal positioning - topBarY = bounds.minY + layoutMargins.top - } + let normalY = bounds.minY + layoutMargins.top + let topBarY = immersiveModeManager.shouldPositionTopBarForImmersive() + ? immersiveModeManager.calculateTopBarY(normalY: normalY) + : normalY topBarContainer.frame = CGRect( x: bounds.minX, @@ -348,8 +343,6 @@ final class PostsPageView: UIView { /// Assumes that various views (top bar container, refresh control container, toolbar) have been laid out. func updateScrollViewInsets() { let scrollView = renderView.scrollView - - // Calculate bottom inset based on immersive mode state let bottomInset = calculateBottomInset() var contentInset = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: bottomInset, right: 0) @@ -358,7 +351,6 @@ final class PostsPageView: UIView { } scrollView.contentInset = contentInset - // Calculate indicator bottom inset based on immersive mode state let indicatorBottomInset = calculateBottomInset() var indicatorInsets = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: indicatorBottomInset, right: 0) @@ -633,7 +625,6 @@ extension PostsPageView: ScrollViewDelegateExtras { viewHasBeenScrolledOnce = true } - // Delegate immersive mode handling to manager immersiveModeManager.handleScrollViewWillBeginDragging(scrollView) } @@ -806,28 +797,34 @@ extension PostsPageView: ScrollViewDelegateExtras { } if #available(iOS 26.0, *) { - let topInset = scrollView.adjustedContentInset.top - let currentOffset = scrollView.contentOffset.y - let topPosition = -topInset + updateNavigationBarForScrollProgress(scrollView) + } + } - let transitionDistance: CGFloat = 30.0 + @available(iOS 26.0, *) + private func updateNavigationBarForScrollProgress(_ scrollView: UIScrollView) { + let topInset = scrollView.adjustedContentInset.top + let currentOffset = scrollView.contentOffset.y + let topPosition = -topInset - let progress: CGFloat - if currentOffset <= topPosition { - progress = 0.0 - } else if currentOffset >= topPosition + transitionDistance { - progress = 1.0 - } else { - let distanceFromTop = currentOffset - topPosition - progress = distanceFromTop / transitionDistance - } + let transitionDistance: CGFloat = 30.0 - // Update navigation bar tint and title view text color based on scroll progress - if let viewController = postsPageViewController, - let navController = viewController.navigationController as? NavigationController { - navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) - viewController.updateTitleViewTextColorForScrollProgress(progress) - } + let progress: CGFloat + if currentOffset <= topPosition { + progress = 0.0 + } else if currentOffset >= topPosition + transitionDistance { + progress = 1.0 + } else { + let distanceFromTop = currentOffset - topPosition + progress = distanceFromTop / transitionDistance } + + guard let viewController = postsPageViewController, + let navController = viewController.navigationController as? NavigationController else { + return + } + + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) + viewController.updateTitleViewTextColorForScrollProgress(progress) } } diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 45dd205d9..683d050f5 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -57,19 +57,21 @@ final class PostsPageViewController: ViewController { private var _liquidGlassTitleView: UIView? @available(iOS 26.0, *) - private var liquidGlassTitleView: LiquidGlassTitleView? { + private var liquidGlassTitleView: LiquidGlassTitleView { if _liquidGlassTitleView == nil { _liquidGlassTitleView = LiquidGlassTitleView() } - return _liquidGlassTitleView as? LiquidGlassTitleView + return _liquidGlassTitleView as! LiquidGlassTitleView } @available(iOS 26.0, *) func updateTitleViewTextColorForScrollProgress(_ progress: CGFloat) { + // Use thresholds (0.01, 0.99) instead of exact 0.0/1.0 to avoid color flickering + // during small scroll position adjustments and floating point precision issues if progress < 0.01 { - liquidGlassTitleView?.textColor = theme["mode"] == "dark" ? .white : .black + liquidGlassTitleView.textColor = theme["mode"] == "dark" ? .white : .black } else if progress > 0.99 { - liquidGlassTitleView?.textColor = nil + liquidGlassTitleView.textColor = nil } } @@ -204,9 +206,9 @@ final class PostsPageViewController: ViewController { didSet { if #available(iOS 26.0, *) { let glassView = liquidGlassTitleView - glassView?.title = title - glassView?.textColor = theme["mode"] == "dark" ? .white : .black - glassView?.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) + glassView.title = title + glassView.textColor = theme["mode"] == "dark" ? .white : .black + glassView.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) navigationItem.titleView = glassView configureNavigationBarForLiquidGlass() @@ -1602,8 +1604,8 @@ final class PostsPageViewController: ViewController { if #available(iOS 26.0, *) { let glassView = liquidGlassTitleView // Set both text color and font from theme - glassView?.textColor = theme["mode"] == "dark" ? .white : .black - glassView?.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) + glassView.textColor = theme["mode"] == "dark" ? .white : .black + glassView.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) // Update navigation bar configuration based on new theme configureNavigationBarForLiquidGlass() diff --git a/App/Views/PageNumberView.swift b/App/Views/PageNumberView.swift index d0ffb89bf..056823a7e 100644 --- a/App/Views/PageNumberView.swift +++ b/App/Views/PageNumberView.swift @@ -11,13 +11,13 @@ final class PageNumberView: UIView { private static let heightModern: CGFloat = 39 // iOS 26+ private static let heightLegacy: CGFloat = 44 // iOS < 26 - private static var currentHeight: CGFloat { + private static let currentHeight: CGFloat = { if #available(iOS 26.0, *) { return heightModern } else { return heightLegacy } - } + }() private let pageLabel: UILabel = { let label = UILabel() From d94edca9277829846216be030c7608faa812c633 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:42:33 +1100 Subject: [PATCH 06/11] Refactor 5 --- App/Navigation/ImmersiveModeManager.swift | 11 +++-------- App/View Controllers/Posts/PostsPageView.swift | 3 ++- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/App/Navigation/ImmersiveModeManager.swift b/App/Navigation/ImmersiveModeManager.swift index 4f2bb70b2..b24bbfde4 100644 --- a/App/Navigation/ImmersiveModeManager.swift +++ b/App/Navigation/ImmersiveModeManager.swift @@ -118,8 +118,6 @@ final class ImmersiveModeManager: NSObject { return scrollableHeight > (totalBarTravelDistance * 2) } - // MARK: - Configuration - /// Configure the manager with required view references func configure( postsView: PostsPageView, @@ -295,14 +293,9 @@ final class ImmersiveModeManager: NSObject { let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) - // When approaching the bottom while scrolling down, progressively reveal bars - // so they're fully visible when reaching the end of content. This prevents users - // from being unable to access navigation when at the bottom of the page. + // Progressively reveal bars when near bottom to ensure they're visible at end of content if isNearBottom && scrollDelta > 0 { - // Calculate target progress based on proximity to bottom (closer = more revealed) let targetProgress = (distanceFromBottom / nearBottomThreshold).clamp(0...1) - // Use min() to cap the reveal rate - can't reveal faster than natural scroll progression - // but can slow down reveal as we approach the bottom (targetProgress becomes constraint) immersiveProgress = min(incrementalProgress, targetProgress).clamp(0...1) } else { immersiveProgress = incrementalProgress.clamp(0...1) @@ -313,6 +306,8 @@ final class ImmersiveModeManager: NSObject { // MARK: - Private Methods + /// Updates the visual state of navigation bars and toolbar based on current immersive progress + /// Uses CATransaction.setDisableActions to prevent implicit animations during scroll-driven transforms private func updateBarsForImmersiveProgress() { guard !isUpdatingBars else { return } isUpdatingBars = true diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index 57731034a..adb332a94 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -719,7 +719,8 @@ extension PostsPageView: ScrollViewDelegateExtras { } func scrollViewDidScroll(_ scrollView: UIScrollView) { - // Guard against concurrent scroll handling + // WKWebView may trigger scroll events on background threads during content loading. + // Dispatch to main thread to ensure safe access to UI state and prevent data races. guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.scrollViewDidScroll(scrollView) From 7a5ea6b9faa960a7c62a8216d1061729c65de389 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:53:47 +1100 Subject: [PATCH 07/11] Final refactor --- App/Navigation/ImmersiveModeManager.swift | 9 +++++++-- App/View Controllers/Posts/GradientView.swift | 4 ++-- App/View Controllers/Posts/PostsPageTopBar.swift | 10 ++++++---- .../Posts/PostsPageViewController.swift | 8 +++++--- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/App/Navigation/ImmersiveModeManager.swift b/App/Navigation/ImmersiveModeManager.swift index b24bbfde4..c36e126d6 100644 --- a/App/Navigation/ImmersiveModeManager.swift +++ b/App/Navigation/ImmersiveModeManager.swift @@ -51,6 +51,12 @@ final class ImmersiveModeManager: NSObject { // MARK: - State Properties + /// Whether scroll events should be processed for immersive mode + private var shouldProcessScroll: Bool { + immersiveModeEnabled + && !UIAccessibility.isVoiceOverRunning + } + /// Immersive mode progress (0.0 = bars fully visible, 1.0 = bars fully hidden) private var _immersiveProgress: CGFloat = 0.0 private var immersiveProgress: CGFloat { @@ -262,8 +268,7 @@ final class ImmersiveModeManager: NSObject { isDecelerating: Bool, isRefreshControlArmedOrTriggered: Bool ) { - guard immersiveModeEnabled, - !UIAccessibility.isVoiceOverRunning, + guard shouldProcessScroll, (isDragging || isDecelerating), !isRefreshControlArmedOrTriggered else { return } diff --git a/App/View Controllers/Posts/GradientView.swift b/App/View Controllers/Posts/GradientView.swift index 8f9aec20b..44cb8b4b0 100644 --- a/App/View Controllers/Posts/GradientView.swift +++ b/App/View Controllers/Posts/GradientView.swift @@ -11,8 +11,8 @@ final class GradientView: UIView { override class var layerClass: AnyClass { CAGradientLayer.self } - - var gradientLayer: CAGradientLayer { + + private var gradientLayer: CAGradientLayer { layer as! CAGradientLayer } diff --git a/App/View Controllers/Posts/PostsPageTopBar.swift b/App/View Controllers/Posts/PostsPageTopBar.swift index 317912685..8ff532a05 100644 --- a/App/View Controllers/Posts/PostsPageTopBar.swift +++ b/App/View Controllers/Posts/PostsPageTopBar.swift @@ -8,6 +8,8 @@ import UIKit final class PostsPageTopBar: UIView, PostsPageTopBarProtocol { + private static let buttonFont = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .regular) + private lazy var stackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [parentForumButton, previousPostsButton, scrollToEndButton]) stackView.distribution = .fillEqually @@ -19,7 +21,7 @@ final class PostsPageTopBar: UIView, PostsPageTopBarProtocol { parentForumButton.accessibilityLabel = LocalizedString("posts-page.parent-forum-button.accessibility-label") parentForumButton.accessibilityHint = LocalizedString("posts-page.parent-forum-button.accessibility-hint") parentForumButton.setTitle(LocalizedString("posts-page.parent-forum-button.title"), for: .normal) - parentForumButton.titleLabel?.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .regular) + parentForumButton.titleLabel?.font = Self.buttonFont return parentForumButton }() @@ -27,15 +29,15 @@ final class PostsPageTopBar: UIView, PostsPageTopBarProtocol { let previousPostsButton = UIButton(type: .system) previousPostsButton.accessibilityLabel = LocalizedString("posts-page.previous-posts-button.accessibility-label") previousPostsButton.setTitle(LocalizedString("posts-page.previous-posts-button.title"), for: .normal) - previousPostsButton.titleLabel?.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .regular) + previousPostsButton.titleLabel?.font = Self.buttonFont return previousPostsButton }() - private let scrollToEndButton: UIButton = { + private lazy var scrollToEndButton: UIButton = { let scrollToEndButton = UIButton(type: .system) scrollToEndButton.accessibilityLabel = LocalizedString("posts-page.scroll-to-end-button.accessibility-label") scrollToEndButton.setTitle(LocalizedString("posts-page.scroll-to-end-button.title"), for: .normal) - scrollToEndButton.titleLabel?.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .regular) + scrollToEndButton.titleLabel?.font = Self.buttonFont return scrollToEndButton }() diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 683d050f5..26379b7e6 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -58,10 +58,12 @@ final class PostsPageViewController: ViewController { @available(iOS 26.0, *) private var liquidGlassTitleView: LiquidGlassTitleView { - if _liquidGlassTitleView == nil { - _liquidGlassTitleView = LiquidGlassTitleView() + if let existingView = _liquidGlassTitleView as? LiquidGlassTitleView { + return existingView } - return _liquidGlassTitleView as! LiquidGlassTitleView + let newView = LiquidGlassTitleView() + _liquidGlassTitleView = newView + return newView } @available(iOS 26.0, *) From 37466e587e6e017cb017c86eba4ec76a41f52346 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:03:55 +1100 Subject: [PATCH 08/11] Moved PostsPageTopBarProtocol to PostsPageTopBarLiquidGlass.swift --- .../Posts/PostsPageTopBarLiquidGlass.swift | 7 +++++++ .../Posts/PostsPageTopBarProtocol.swift | 14 -------------- Awful.xcodeproj/project.pbxproj | 4 ---- 3 files changed, 7 insertions(+), 18 deletions(-) delete mode 100644 App/View Controllers/Posts/PostsPageTopBarProtocol.swift diff --git a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift index a40ab3899..ac31eb5b1 100644 --- a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift +++ b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift @@ -180,3 +180,10 @@ final class PostsPageTopBarLiquidGlass: UIView, PostsPageTopBarProtocol { } } +protocol PostsPageTopBarProtocol: UIView { + var goToParentForum: (() -> Void)? { get set } + var showPreviousPosts: (() -> Void)? { get set } + var scrollToEnd: (() -> Void)? { get set } + + func themeDidChange(_ theme: Theme) +} diff --git a/App/View Controllers/Posts/PostsPageTopBarProtocol.swift b/App/View Controllers/Posts/PostsPageTopBarProtocol.swift deleted file mode 100644 index 9e58fc2fd..000000000 --- a/App/View Controllers/Posts/PostsPageTopBarProtocol.swift +++ /dev/null @@ -1,14 +0,0 @@ -// PostsPageTopBarProtocol.swift -// -// Copyright 2025 Awful Contributors - -import AwfulTheming -import UIKit - -protocol PostsPageTopBarProtocol: UIView { - var goToParentForum: (() -> Void)? { get set } - var showPreviousPosts: (() -> Void)? { get set } - var scrollToEnd: (() -> Void)? { get set } - - func themeDidChange(_ theme: Theme) -} diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index 0b47ef71c..9bdeaad8b 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -204,7 +204,6 @@ 2D327DD627F468CE00D21AB0 /* BookmarkColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */; }; 2D571B492EC8765F0026826C /* ImmersiveModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B482EC876590026826C /* ImmersiveModeManager.swift */; }; 2D571B4B2EC876A20026826C /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4A2EC8769E0026826C /* GradientView.swift */; }; - 2D571B4D2EC878230026826C /* PostsPageTopBarProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4C2EC8781E0026826C /* PostsPageTopBarProtocol.swift */; }; 2D571B4F2EC878A70026826C /* PostsPageTopBarLiquidGlass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */; }; 2D571B512EC8795B0026826C /* LiquidGlassTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B502EC879590026826C /* LiquidGlassTitleView.swift */; }; 2D571B532EC87B010026826C /* PageNumberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B522EC87AFE0026826C /* PageNumberView.swift */; }; @@ -525,7 +524,6 @@ 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkColorPicker.swift; sourceTree = ""; }; 2D571B482EC876590026826C /* ImmersiveModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveModeManager.swift; sourceTree = ""; }; 2D571B4A2EC8769E0026826C /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; - 2D571B4C2EC8781E0026826C /* PostsPageTopBarProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsPageTopBarProtocol.swift; sourceTree = ""; }; 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsPageTopBarLiquidGlass.swift; sourceTree = ""; }; 2D571B502EC879590026826C /* LiquidGlassTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassTitleView.swift; sourceTree = ""; }; 2D571B522EC87AFE0026826C /* PageNumberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageNumberView.swift; sourceTree = ""; }; @@ -761,7 +759,6 @@ isa = PBXGroup; children = ( 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */, - 2D571B4C2EC8781E0026826C /* PostsPageTopBarProtocol.swift */, 2D571B4A2EC8769E0026826C /* GradientView.swift */, 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */, 1C16FC191CD42EB300C88BD1 /* PostPreviewViewController.swift */, @@ -1650,7 +1647,6 @@ 1C0D7FFE1CF38CA2003EE2D1 /* PostsPageViewController.swift in Sources */, 1CC256B71A39A6BE003FA7A8 /* AwfulBrowser.swift in Sources */, 1CF280982055EB9B00913149 /* AwfulRoute.swift in Sources */, - 2D571B4D2EC878230026826C /* PostsPageTopBarProtocol.swift in Sources */, 1CD9FB641D1A38030070C8C7 /* NigglyRefreshView.swift in Sources */, 1CF6786E201E8F45009A9640 /* MessageListCell.swift in Sources */, 1C16FBB01CB863E600C88BD1 /* IconActionItem.swift in Sources */, From 4b76f2ac181365d52ebb3c379a5ee0e6a2eaac27 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:21:19 +1100 Subject: [PATCH 09/11] Updated PostsPageViewController to align with earlier Liquid Glass branch --- .../Posts/PostsPageViewController.swift | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 26379b7e6..06a6f80bb 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -41,6 +41,8 @@ final class PostsPageViewController: ViewController { @FoilDefaultStorage(Settings.jumpToPostEndOnDoubleTap) private var jumpToPostEndOnDoubleTap private var jumpToPostIDAfterLoading: String? private var messageViewController: MessageComposeViewController? + // Stored as Any because Task returns non-Sendable Core Data objects (Post: NSManagedObject). + // Swift 6 requires Task Success types to be Sendable. private var networkOperation: Any? private var observers: [NSKeyValueObservation] = [] private lazy var oEmbedFetcher: OEmbedFetcher = .init() @@ -54,6 +56,7 @@ final class PostsPageViewController: ViewController { let thread: AwfulThread private var webViewDidLoadOnce = false + // this is to overcome not being allowed to mark stored properties as potentially unavailable using @available private var _liquidGlassTitleView: UIView? @available(iOS 26.0, *) @@ -148,6 +151,12 @@ final class PostsPageViewController: ViewController { init() { super.init(frame: .zero) showsMenuAsPrimaryAction = true + + if #available(iOS 16.0, *) { + preferredMenuElementOrder = .fixed + } + + updateInterfaceStyle() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -155,8 +164,17 @@ final class PostsPageViewController: ViewController { func show(menu: UIMenu, from rect: CGRect) { frame = rect self.menu = menu + + updateInterfaceStyle() + gestureRecognizers?.first { "\(type(of: $0))".contains("TouchDown") }?.touchesBegan([], with: .init()) } + + func updateInterfaceStyle() { + // Follow the theme's menuAppearance setting for menu appearance + let menuAppearance = Theme.defaultTheme()[string: "menuAppearance"] + overrideUserInterfaceStyle = menuAppearance == "light" ? .light : .dark + } } private struct FYADFlagRequest: RenderViewMessage { @@ -213,6 +231,7 @@ final class PostsPageViewController: ViewController { glassView.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) navigationItem.titleView = glassView + configureNavigationBarForLiquidGlass() } else { navigationItem.titleView = nil @@ -463,6 +482,11 @@ final class PostsPageViewController: ViewController { private lazy var composeItem: UIBarButtonItem = { let item = UIBarButtonItem(image: UIImage(named: "compose"), style: .plain, target: self, action: #selector(compose)) item.accessibilityLabel = NSLocalizedString("compose.accessibility-label", comment: "") + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + } else { + item.tintColor = theme["navigationBarTextColor"] + } return item }() @@ -554,6 +578,11 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Settings" + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + } else { + item.tintColor = theme["toolbarTextColor"] + } return item }() @@ -569,6 +598,11 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Previous page" + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + } else { + item.tintColor = theme["toolbarTextColor"] + } return item }() @@ -590,7 +624,7 @@ final class PostsPageViewController: ViewController { popover.barButtonItem = action.sender as? UIBarButtonItem } }) - + if #available(iOS 26.0, *) { let containerView = UIView() containerView.addSubview(pageNumberView) @@ -605,7 +639,7 @@ final class PostsPageViewController: ViewController { } else { item.possibleTitles = ["2345 / 2345"] } - + item.accessibilityHint = "Opens page picker" return item }() @@ -622,7 +656,7 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Next page" - + // Only set explicit tint color for iOS < 26 if #available(iOS 26.0, *) { } else { item.tintColor = theme["toolbarTextColor"] @@ -1810,6 +1844,7 @@ final class PostsPageViewController: ViewController { awfulNavigationBar.bottomBorderColor = .clear } + // Start with opaque background - NavigationController will handle the transition to clear on scroll let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = theme["navigationBarTintColor"] @@ -1834,18 +1869,23 @@ final class PostsPageViewController: ViewController { appearance.backButtonAppearance.normal.titleTextAttributes = buttonAttributes appearance.backButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + // Set the back indicator image with template mode if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } + // Apply to all states navigationBar.standardAppearance = appearance navigationBar.scrollEdgeAppearance = appearance navigationBar.compactAppearance = appearance navigationBar.compactScrollEdgeAppearance = appearance + // Set tintColor AFTER applying appearance to ensure back button uses theme color let navTextColor: UIColor = theme["mode"] == "dark" ? .white : .black navigationBar.tintColor = navTextColor + // Force the navigation controller to start at scroll position 0 (top) + // This will also update tintColor based on scroll position if needed navController.updateNavigationBarTintForScrollProgress(NSNumber(value: 0.0)) navigationBar.setNeedsLayout() @@ -1887,6 +1927,7 @@ final class PostsPageViewController: ViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + userActivity = nil } From f0e68e308e21d4c03b81cf2e0eed811bcc4e88fb Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:37:09 +1100 Subject: [PATCH 10/11] Changed the way top and bottom bars return into view when at the bottom of the scroll. Previously bars would scroll back into place but this would only succeed in reaching their destination position about 80% of the time. Now the bars will fade back into view already in position. Sliding behaviour still applies to disappearing at the top of the scroll and returning when not at the bottom. --- .../ShowSmilieKeyboardCommand.swift | 19 +-- App/Navigation/ImmersiveModeManager.swift | 156 ++++++++++++++---- 2 files changed, 132 insertions(+), 43 deletions(-) diff --git a/App/Composition/ShowSmilieKeyboardCommand.swift b/App/Composition/ShowSmilieKeyboardCommand.swift index 6cc155b5e..8197a079f 100644 --- a/App/Composition/ShowSmilieKeyboardCommand.swift +++ b/App/Composition/ShowSmilieKeyboardCommand.swift @@ -68,30 +68,29 @@ final class ShowSmilieKeyboardCommand: NSObject { // Dismiss keyboard before showing smilie picker textView.resignFirstResponder() - - weak var weakTextView = textView - let pickerView = SmiliePickerView(dataStore: smilieKeyboard.dataStore) { [weak self] smilie in + + let pickerView = SmiliePickerView(dataStore: smilieKeyboard.dataStore) { [weak self, weak textView] smilie in self?.insertSmilie(smilie) // Delay keyboard reactivation to ensure smooth animation after sheet dismissal // Without this delay, the keyboard animation can conflict with sheet dismissal DispatchQueue.main.async { - weakTextView?.becomeFirstResponder() + textView?.becomeFirstResponder() } } - .onDisappear { + .onDisappear { [weak textView] in // Delay keyboard reactivation when view disappears (handles Done button case) // This ensures the sheet dismissal animation completes before keyboard appears DispatchQueue.main.async { - weakTextView?.becomeFirstResponder() + textView?.becomeFirstResponder() } } .themed() - + let hostingController = UIHostingController(rootView: pickerView) - hostingController.modalPresentationStyle = .pageSheet - + hostingController.modalPresentationStyle = UIModalPresentationStyle.pageSheet + if let sheet = hostingController.sheetPresentationController { - sheet.detents = [.medium(), .large()] + sheet.detents = [UISheetPresentationController.Detent.medium(), UISheetPresentationController.Detent.large()] sheet.prefersGrabberVisible = true sheet.preferredCornerRadius = 20 sheet.delegate = self diff --git a/App/Navigation/ImmersiveModeManager.swift b/App/Navigation/ImmersiveModeManager.swift index c36e126d6..207944833 100644 --- a/App/Navigation/ImmersiveModeManager.swift +++ b/App/Navigation/ImmersiveModeManager.swift @@ -42,7 +42,10 @@ final class ImmersiveModeManager: NSObject { postsView?.layoutIfNeeded() } else if !immersiveModeEnabled && oldValue { immersiveProgress = 0.0 + isInBottomFadeMode = false + bottomFadeProgress = 0.0 resetAllTransforms() + restoreBarAlphas() safeAreaGradientView.alpha = 0.0 postsView?.setNeedsLayout() } @@ -81,6 +84,10 @@ final class ImmersiveModeManager: NSObject { private var isUpdatingBars = false private var cachedTotalBarTravelDistance: CGFloat? + // Bottom fade mode state - used instead of sliding when at bottom of scroll + private var isInBottomFadeMode = false + private var bottomFadeProgress: CGFloat = 0.0 // 0 = transparent, 1 = opaque + // MARK: - UI Elements lazy var safeAreaGradientView: GradientView = { @@ -144,20 +151,22 @@ final class ImmersiveModeManager: NSObject { /// Force exit immersive mode (useful for scroll-to-top/bottom actions) func exitImmersiveMode() { - guard immersiveModeEnabled && immersiveProgress > 0 else { return } - immersiveProgress = 0.0 + guard immersiveModeEnabled else { return } - if let navBar = findNavigationBar() { - navBar.transform = .identity + if isInBottomFadeMode { + isInBottomFadeMode = false + bottomFadeProgress = 0.0 + restoreBarAlphas() } + + guard immersiveProgress > 0 else { return } + immersiveProgress = 0.0 } - /// Check if immersive mode should affect scroll insets func shouldAdjustScrollInsets() -> Bool { return immersiveModeEnabled } - /// Calculate bottom inset adjustment for immersive mode func calculateBottomInset(normalBottomInset: CGFloat) -> CGFloat { guard immersiveModeEnabled, let toolbar = toolbar, @@ -170,7 +179,6 @@ final class ImmersiveModeManager: NSObject { return max(postsView.layoutMargins.bottom, postsView.bounds.maxY - staticToolbarY) } - /// Update layout for gradient view func updateGradientLayout(in containerView: UIView) { guard #available(iOS 26.0, *) else { return } @@ -193,32 +201,28 @@ final class ImmersiveModeManager: NSObject { } } - /// Determine if top bar should be positioned for immersive mode func shouldPositionTopBarForImmersive() -> Bool { return immersiveModeEnabled } - /// Calculate top bar Y position for immersive mode func calculateTopBarY(normalY: CGFloat) -> CGFloat { guard immersiveModeEnabled else { return normalY } if let navBar = findNavigationBar() { return navBar.frame.maxY } else { - return postsView?.bounds.minY ?? 0 + (postsView?.layoutMargins.top ?? 0) + 44 + return (postsView?.bounds.minY ?? 0) + (postsView?.layoutMargins.top ?? 0) + 44 } } // MARK: - Scroll View Delegate Methods - /// Handle scroll view content size changes func handleScrollViewDidChangeContentSize(_ scrollView: UIScrollView) { if immersiveModeEnabled && !isContentScrollableEnoughForImmersive { immersiveProgress = 0 } } - /// Handle scroll view will begin dragging func handleScrollViewWillBeginDragging(_ scrollView: UIScrollView) { lastScrollOffset = scrollView.contentOffset.y @@ -227,7 +231,6 @@ final class ImmersiveModeManager: NSObject { } } - /// Handle scroll view will end dragging func handleScrollViewWillEndDragging( _ scrollView: UIScrollView, withVelocity velocity: CGPoint, @@ -243,7 +246,6 @@ final class ImmersiveModeManager: NSObject { } } - /// Handle scroll view did end dragging func handleScrollViewDidEndDragging( _ scrollView: UIScrollView, willDecelerate: Bool, @@ -253,7 +255,6 @@ final class ImmersiveModeManager: NSObject { snapToVisibleIfAtBottom(scrollView, isRefreshControlArmedOrTriggered: isRefreshControlArmedOrTriggered) } - /// Handle scroll view did end decelerating func handleScrollViewDidEndDecelerating( _ scrollView: UIScrollView, isRefreshControlArmedOrTriggered: Bool @@ -269,42 +270,71 @@ final class ImmersiveModeManager: NSObject { isRefreshControlArmedOrTriggered: Bool ) { guard shouldProcessScroll, - (isDragging || isDecelerating), !isRefreshControlArmedOrTriggered else { return } + // Always check for bottom, even when not actively scrolling + let distanceFromBottom = calculateDistanceFromBottom(scrollView) + let barTravelDistance = totalBarTravelDistance + let bottomFadeZone = barTravelDistance * Self.progressiveRevealMultiplier + let isNearBottom = distanceFromBottom <= bottomFadeZone + + if isNearBottom { + if !isInBottomFadeMode { + isInBottomFadeMode = true + bottomFadeProgress = 1.0 + updateBarsForBottomFade() + } + lastScrollOffset = scrollView.contentOffset.y + return + } + + // For everything else, require active scrolling + guard isDragging || isDecelerating else { return } + let currentOffset = scrollView.contentOffset.y let scrollDelta = currentOffset - lastScrollOffset guard isContentScrollableEnoughForImmersive else { + if isInBottomFadeMode { + exitBottomFadeMode() + } immersiveProgress = 0 lastScrollOffset = currentOffset return } if currentOffset < Self.topProximityThreshold { + if isInBottomFadeMode { + exitBottomFadeMode() + } immersiveProgress = 0 lastScrollOffset = currentOffset return } + // Handle fading out when scrolling away from bottom + if isInBottomFadeMode { + let fadeOutDistance: CGFloat = 50.0 + let distancePastThreshold = distanceFromBottom - bottomFadeZone + let fadeProgress = 1.0 - (distancePastThreshold / fadeOutDistance) + bottomFadeProgress = fadeProgress.clamp(0...1) + + if bottomFadeProgress > 0 { + updateBarsForBottomFade() + lastScrollOffset = currentOffset + return + } else { + exitBottomFadeMode() + } + } + + // For normal sliding behavior, ignore tiny scroll deltas guard abs(scrollDelta) > Self.minScrollDelta else { return } - let distanceFromBottom = calculateDistanceFromBottom(scrollView) - let barTravelDistance = totalBarTravelDistance - let nearBottomThreshold = barTravelDistance * Self.progressiveRevealMultiplier - let isNearBottom = distanceFromBottom <= nearBottomThreshold - let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) - - // Progressively reveal bars when near bottom to ensure they're visible at end of content - if isNearBottom && scrollDelta > 0 { - let targetProgress = (distanceFromBottom / nearBottomThreshold).clamp(0...1) - immersiveProgress = min(incrementalProgress, targetProgress).clamp(0...1) - } else { - immersiveProgress = incrementalProgress.clamp(0...1) - } + immersiveProgress = incrementalProgress.clamp(0...1) lastScrollOffset = currentOffset } @@ -315,6 +345,10 @@ final class ImmersiveModeManager: NSObject { /// Uses CATransaction.setDisableActions to prevent implicit animations during scroll-driven transforms private func updateBarsForImmersiveProgress() { guard !isUpdatingBars else { return } + + // Don't apply transforms when in bottom fade mode - alpha controls visibility instead + guard !isInBottomFadeMode else { return } + isUpdatingBars = true defer { isUpdatingBars = false } @@ -345,6 +379,47 @@ final class ImmersiveModeManager: NSObject { CATransaction.commit() } + /// Updates bar visibility using alpha/opacity for bottom fade mode + /// Bars remain in their normal position (no transforms) and fade in/out + private func updateBarsForBottomFade() { + guard !isUpdatingBars else { return } + isUpdatingBars = true + defer { isUpdatingBars = false } + + CATransaction.begin() + CATransaction.setDisableActions(true) + + resetAllTransforms() + + let alpha = bottomFadeProgress + + if let navBar = findNavigationBar() { + navBar.alpha = alpha + } + topBarContainer?.alpha = alpha + toolbar?.alpha = alpha + + // Gradient view should be hidden when bars are visible via fade + safeAreaGradientView.alpha = 0.0 + + CATransaction.commit() + } + + /// Exits bottom fade mode and restores bars to hidden state via transforms + private func exitBottomFadeMode() { + guard isInBottomFadeMode else { return } + + isInBottomFadeMode = false + bottomFadeProgress = 0.0 + + // Restore alpha to full before applying transforms + restoreBarAlphas() + + // Set immersive progress to fully hidden and apply transforms + _immersiveProgress = 1.0 + updateBarsForImmersiveProgress() + } + private func calculateNavigationBarTransform() -> CGFloat { guard let navBar = findNavigationBar() else { return 0 } @@ -371,6 +446,14 @@ final class ImmersiveModeManager: NSObject { toolbar?.transform = .identity } + private func restoreBarAlphas() { + if let navBar = findNavigationBar() { + navBar.alpha = 1.0 + } + topBarContainer?.alpha = 1.0 + toolbar?.alpha = 1.0 + } + /// Calculates the remaining scrollable distance from current position to the bottom of content /// /// The calculation accounts for: @@ -387,12 +470,19 @@ final class ImmersiveModeManager: NSObject { } private func snapToVisibleIfAtBottom(_ scrollView: UIScrollView, isRefreshControlArmedOrTriggered: Bool) { - if immersiveModeEnabled && !isRefreshControlArmedOrTriggered && isContentScrollableEnoughForImmersive { - let distanceFromBottom = calculateDistanceFromBottom(scrollView) + guard immersiveModeEnabled && !isRefreshControlArmedOrTriggered && isContentScrollableEnoughForImmersive else { return } + + let distanceFromBottom = calculateDistanceFromBottom(scrollView) + let bottomFadeZone = totalBarTravelDistance * Self.progressiveRevealMultiplier - if distanceFromBottom <= Self.bottomDistanceThreshold && immersiveProgress < Self.bottomSnapThreshold { - immersiveProgress = 0 + // If near bottom, ensure bars are fully visible via fade mode + if distanceFromBottom <= bottomFadeZone { + if !isInBottomFadeMode { + isInBottomFadeMode = true } + // Snap to fully visible when scroll stops near bottom + bottomFadeProgress = 1.0 + updateBarsForBottomFade() } } From c07d8b571cdc3b2f2f5f1c82f36ec22ae3b82a1c Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:45:39 +1100 Subject: [PATCH 11/11] Fixed frog lottie positioning --- App/View Controllers/Posts/PostsPageView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index adb332a94..9532f6c59 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -81,7 +81,9 @@ final class PostsPageView: UIView { NSLayoutConstraint.activate([ refreshControl.leftAnchor.constraint(equalTo: containerMargins.leftAnchor), containerMargins.rightAnchor.constraint(equalTo: refreshControl.rightAnchor), - containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor) + containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor), + // controls frog lottie position between last post and bottom toolbar + refreshControl.heightAnchor.constraint(equalToConstant: 90) ]) } }