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 new file mode 100644 index 000000000..207944833 --- /dev/null +++ b/App/Navigation/ImmersiveModeManager.swift @@ -0,0 +1,528 @@ +// ImmersiveModeManager.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Foundation +import UIKit +import AwfulSettings + +// MARK: - ImmersiveModeManager + +/// Manages immersive mode behavior for posts view +/// Handles hiding/showing navigation bars and toolbars with scroll gestures +@MainActor +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 + + weak var postsView: PostsPageView? + weak var navigationController: UINavigationController? + weak var renderView: RenderView? + weak var toolbar: UIToolbar? + weak var topBarContainer: UIView? + + // MARK: - Configuration + + /// Whether immersive mode is enabled in settings + @FoilDefaultStorage(Settings.immersiveModeEnabled) private var immersiveModeEnabled { + didSet { + if immersiveModeEnabled && !oldValue { + postsView?.setNeedsLayout() + postsView?.layoutIfNeeded() + } else if !immersiveModeEnabled && oldValue { + immersiveProgress = 0.0 + isInBottomFadeMode = false + bottomFadeProgress = 0.0 + resetAllTransforms() + restoreBarAlphas() + safeAreaGradientView.alpha = 0.0 + postsView?.setNeedsLayout() + } + } + } + + // 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 { + get { _immersiveProgress } + set { + let clampedValue = newValue.clamp(0...1) + + guard immersiveModeEnabled && !UIAccessibility.isVoiceOverRunning else { + _immersiveProgress = 0.0 + return + } + + guard _immersiveProgress != clampedValue else { return } + + _immersiveProgress = clampedValue + updateBarsForImmersiveProgress() + } + } + + private var lastScrollOffset: CGFloat = 0 + private weak var cachedNavigationBar: UINavigationBar? + 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 = { + 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 { + if let cached = cachedTotalBarTravelDistance { + return cached + } + + 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 + Self.bottomProximityDistance + cachedTotalBarTravelDistance = max(bottomDistance, topDistance) + } else { + cachedTotalBarTravelDistance = bottomDistance + } + + return cachedTotalBarTravelDistance ?? 100 + } + + /// 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) + } + + /// 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 + cachedNavigationBar = nil + } + + // MARK: - Public Methods + + /// Force exit immersive mode (useful for scroll-to-top/bottom actions) + func exitImmersiveMode() { + guard immersiveModeEnabled else { return } + + if isInBottomFadeMode { + isInBottomFadeMode = false + bottomFadeProgress = 0.0 + restoreBarAlphas() + } + + guard immersiveProgress > 0 else { return } + immersiveProgress = 0.0 + } + + func shouldAdjustScrollInsets() -> Bool { + return immersiveModeEnabled + } + + func calculateBottomInset(normalBottomInset: CGFloat) -> CGFloat { + guard immersiveModeEnabled, + let toolbar = toolbar, + let postsView = postsView else { + return normalBottomInset + } + + 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) + } + + 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 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() + } + } + + func shouldPositionTopBarForImmersive() -> Bool { + return immersiveModeEnabled + } + + 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 + } + } + + // MARK: - Scroll View Delegate Methods + + func handleScrollViewDidChangeContentSize(_ scrollView: UIScrollView) { + if immersiveModeEnabled && !isContentScrollableEnoughForImmersive { + immersiveProgress = 0 + } + } + + func handleScrollViewWillBeginDragging(_ scrollView: UIScrollView) { + lastScrollOffset = scrollView.contentOffset.y + + if immersiveModeEnabled && scrollView.contentOffset.y < Self.topProximityThreshold { + immersiveProgress = 0 + } + } + + func handleScrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer, + isRefreshControlArmedOrTriggered: Bool + ) { + if immersiveModeEnabled && !isRefreshControlArmedOrTriggered { + if immersiveProgress > Self.snapToHiddenThreshold { + immersiveProgress = 1.0 + } else if immersiveProgress < Self.snapToVisibleThreshold { + immersiveProgress = 0.0 + } + } + } + + func handleScrollViewDidEndDragging( + _ scrollView: UIScrollView, + willDecelerate: Bool, + isRefreshControlArmedOrTriggered: Bool + ) { + guard !willDecelerate else { return } + snapToVisibleIfAtBottom(scrollView, isRefreshControlArmedOrTriggered: isRefreshControlArmedOrTriggered) + } + + func handleScrollViewDidEndDecelerating( + _ scrollView: UIScrollView, + isRefreshControlArmedOrTriggered: Bool + ) { + snapToVisibleIfAtBottom(scrollView, isRefreshControlArmedOrTriggered: isRefreshControlArmedOrTriggered) + } + + /// Main scroll handling logic for immersive mode + func handleScrollViewDidScroll( + _ scrollView: UIScrollView, + isDragging: Bool, + isDecelerating: Bool, + isRefreshControlArmedOrTriggered: Bool + ) { + guard shouldProcessScroll, + !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 incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) + immersiveProgress = incrementalProgress.clamp(0...1) + + lastScrollOffset = currentOffset + } + + // 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 } + + // Don't apply transforms when in bottom fade mode - alpha controls visibility instead + guard !isInBottomFadeMode else { return } + + isUpdatingBars = true + defer { isUpdatingBars = false } + + CATransaction.begin() + CATransaction.setDisableActions(true) + + guard immersiveModeEnabled && immersiveProgress > 0 else { + safeAreaGradientView.alpha = 0.0 + resetAllTransforms() + CATransaction.commit() + return + } + + safeAreaGradientView.alpha = immersiveProgress + + let navBarTransform = calculateNavigationBarTransform() + if let navBar = findNavigationBar() { + navBar.transform = CGAffineTransform(translationX: 0, y: navBarTransform) + } + + topBarContainer?.transform = CGAffineTransform(translationX: 0, y: navBarTransform) + + if let toolbar = toolbar { + let toolbarTransform = calculateToolbarTransform() + toolbar.transform = CGAffineTransform(translationX: 0, y: toolbarTransform) + } + + 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 } + + 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 + } + topBarContainer?.transform = .identity + 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: + /// - 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 + let maxOffsetY = max(contentHeight, scrollView.bounds.height - adjustedBottom) - scrollView.bounds.height + adjustedBottom + return maxOffsetY - scrollView.contentOffset.y + } + + private func snapToVisibleIfAtBottom(_ scrollView: UIScrollView, isRefreshControlArmedOrTriggered: Bool) { + guard immersiveModeEnabled && !isRefreshControlArmedOrTriggered && isContentScrollableEnoughForImmersive else { return } + + let distanceFromBottom = calculateDistanceFromBottom(scrollView) + let bottomFadeZone = totalBarTravelDistance * Self.progressiveRevealMultiplier + + // 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() + } + } + + 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 + } +} + +// 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..dfc394868 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -14,6 +14,15 @@ 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 + } + + 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 } @@ -46,13 +55,40 @@ final class NavigationController: UINavigationController, Themeable { return navigationBar as! NavigationBar } + @available(iOS 26.0, *) + private func createGradientBackgroundImage(from color: UIColor, size: CGSize = gradientImageSize) -> 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 +101,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 +158,192 @@ 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() + + 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"] + } else if progress > ScrollProgress.fullyScrolled { + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.backgroundImage = nil + } else { + appearance.configureWithTransparentBackground() + + guard let opaqueColor = theme[uicolor: "navigationBarTintColor"], + let gradientBaseColor = theme[uicolor: "listHeaderBackgroundColor"] else { + return + } + + 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) + } + } + } + + @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) + } + } else { + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + } + } + + @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 + } + + 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) + } + + @available(iOS 26.0, *) + private func applyAppearance(_ appearance: UINavigationBarAppearance, progress: CGFloat) { + 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 + + // 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 + 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"] + if #available(iOS 26.0, *) { + awfulNavigationBar.bottomBorderColor = .clear + } else { + awfulNavigationBar.bottomBorderColor = theme["topBarBottomBorderColor"] + } + + 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 +351,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 = theme[uicolor: "navigationBarTextColor"] ?? .label + + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + initialAppearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + + initialAppearance.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(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 = 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) + + awfulNavigationBar.standardAppearance = appearance + awfulNavigationBar.scrollEdgeAppearance = appearance + awfulNavigationBar.compactAppearance = appearance + awfulNavigationBar.compactScrollEdgeAppearance = appearance + + awfulNavigationBar.tintColor = textColor + + awfulNavigationBar.setNeedsLayout() + awfulNavigationBar.layoutIfNeeded() + } + } else { + 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) + ] + 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 +489,46 @@ 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 { + guard let textColor = vcTheme[uicolor: "navigationBarTextColor"] else { return } + + awfulNavigationBar.tintColor = textColor + + 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..44cb8b4b0 --- /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 + } + + private 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..15779e506 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.immersiveModeEnabled) private var immersiveModeEnabled @FoilDefaultStorage(Settings.showAvatars) private var showAvatars @FoilDefaultStorage(Settings.loadImages) private var showImages @@ -33,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 } @@ -68,10 +61,25 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati @IBOutlet private var darkModeLabel: UILabel! @IBOutlet private var darkModeSwitch: UISwitch! @IBAction func toggleDarkMode(_ sender: UISwitch) { + 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() } - darkMode = sender.isOn } private lazy var fontScaleFormatter: NumberFormatter = { @@ -118,6 +126,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 +138,65 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati .receive(on: RunLoop.main) .sink { [weak self] in self?.imagesSwitch?.isOn = $0 } .store(in: &cancellables) + + $immersiveModeEnabled + .receive(on: RunLoop.main) + .sink { [weak self] in self?.immersiveModeSwitch?.isOn = $0 } + .store(in: &cancellables) + + DispatchQueue.main.async { [weak self] in + self?.setupImmersiveModeUI() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updatePreferredContentSize() + } + + private func setupImmersiveModeUI() { + guard isViewLoaded, immersiveModeStack == nil else { return } + + let label = UILabel() + label.text = "Immersive Mode" + label.font = UIFont.preferredFont(forTextStyle: .body) + label.textColor = theme["sheetTextColor"] ?? UIColor.label + immersiveModeLabel = label + + let modeSwitch = UISwitch() + modeSwitch.isOn = immersiveModeEnabled + modeSwitch.onTintColor = theme["settingsSwitchColor"] + 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 + immersiveModeStack = 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 +213,9 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati for uiswitch in switches { uiswitch.onTintColor = theme["settingsSwitchColor"] } + + immersiveModeLabel?.textColor = theme["sheetTextColor"] ?? UIColor.label + immersiveModeSwitch?.onTintColor = theme["settingsSwitchColor"] } // MARK: UIAdaptivePresentationControllerDelegate diff --git a/App/View Controllers/Posts/PostsPageTopBar.swift b/App/View Controllers/Posts/PostsPageTopBar.swift index 475a176ff..8ff532a05 100644 --- a/App/View Controllers/Posts/PostsPageTopBar.swift +++ b/App/View Controllers/Posts/PostsPageTopBar.swift @@ -6,7 +6,9 @@ import AwfulSettings import AwfulTheming import UIKit -final class PostsPageTopBar: UIView { +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]) @@ -19,7 +21,7 @@ final class PostsPageTopBar: UIView { 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 { 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/PostsPageTopBarLiquidGlass.swift b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift new file mode 100644 index 000000000..ac31eb5b1 --- /dev/null +++ b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift @@ -0,0 +1,189 @@ +// 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() } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +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..9532f6c59 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -20,6 +20,11 @@ final class PostsPageView: UIView { @FoilDefaultStorage(Settings.darkMode) private var darkMode @FoilDefaultStorage(Settings.frogAndGhostEnabled) private var frogAndGhostEnabled 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 @@ -64,7 +69,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,12 +77,13 @@ 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), 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) ]) } } @@ -174,11 +179,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 +239,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 immersiveModeManager.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 +271,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(immersiveModeManager.safeAreaGradientView) + } else { + addSubview(fallbackSafeAreaGradientView) + } addSubview(topBarContainer) addSubview(loadingViewContainer) addSubview(toolbar) renderView.scrollView.addSubview(refreshControlContainer) + immersiveModeManager.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 +303,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, *) { + immersiveModeManager.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,31 +326,52 @@ final class PostsPageView: UIView { height: refreshControlHeight) let topBarHeight = topBarContainer.layoutFittingCompressedHeight(targetWidth: bounds.width) + + let normalY = bounds.minY + layoutMargins.top + let topBarY = immersiveModeManager.shouldPositionTopBarForImmersive() + ? immersiveModeManager.calculateTopBarY(normalY: normalY) + : normalY + topBarContainer.frame = CGRect( x: bounds.minX, - y: bounds.minY + layoutMargins.top, + y: topBarY, width: bounds.width, height: topBarHeight) updateTopBarContainerFrameAndScrollViewInsets() + + immersiveModeManager.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 + let bottomInset = calculateBottomInset() - var contentInset = UIEdgeInsets(top: topBarContainer.frame.maxY, left: 0, bottom: bounds.maxY - toolbar.frame.minY, right: 0) + 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) + 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. indicatorInsets.top -= layoutMargins.top indicatorInsets.bottom -= layoutMargins.bottom 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 @@ -322,11 +402,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 +431,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 +456,10 @@ extension PostsPageView { clipsToBounds = true + if #available(iOS 26.0, *) { + backgroundColor = .clear + } + addSubview(topBar, constrainEdges: [.bottom, .left, .right]) } @@ -458,6 +563,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 +615,48 @@ extension PostsPageView { extension PostsPageView: ScrollViewDelegateExtras { func scrollViewDidChangeContentSize(_ scrollView: UIScrollView) { setNeedsLayout() + + immersiveModeManager.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 } + + immersiveModeManager.handleScrollViewWillBeginDragging(scrollView) } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + + immersiveModeManager.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 immersive mode is disabled + if !immersiveModeManager.shouldPositionTopBarForImmersive() { + 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 +678,12 @@ extension PostsPageView: ScrollViewDelegateExtras { if !willDecelerate { updateTopBarDidEndDecelerating() + + immersiveModeManager.handleScrollViewDidEndDragging( + scrollView, + willDecelerate: willDecelerate, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) } willBeginDraggingContentOffset = nil @@ -564,6 +699,11 @@ extension PostsPageView: ScrollViewDelegateExtras { } updateTopBarDidEndDecelerating() + + immersiveModeManager.handleScrollViewDidEndDecelerating( + scrollView, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) } private func updateTopBarDidEndDecelerating() { @@ -581,6 +721,15 @@ extension PostsPageView: ScrollViewDelegateExtras { } func scrollViewDidScroll(_ scrollView: UIScrollView) { + // 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) + } + return + } + let info = ScrollViewInfo(refreshControlHeight: refreshControlContainer.bounds.height, scrollView: scrollView) // Update refreshControlState first, then decide if we care about topBarState. @@ -612,6 +761,13 @@ extension PostsPageView: ScrollViewDelegateExtras { break } + immersiveModeManager.handleScrollViewDidScroll( + scrollView, + isDragging: scrollView.isDragging, + isDecelerating: scrollView.isDecelerating, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) + switch topBarState { case .appearing, .disappearing: updateTopBarContainerFrameAndScrollViewInsets() @@ -642,5 +798,36 @@ extension PostsPageView: ScrollViewDelegateExtras { break } } + + if #available(iOS 26.0, *) { + updateNavigationBarForScrollProgress(scrollView) + } + } + + @available(iOS 26.0, *) + private func updateNavigationBarForScrollProgress(_ scrollView: UIScrollView) { + 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 + } + + 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 31e12e867..06a6f80bb 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -56,6 +56,30 @@ 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, *) + private var liquidGlassTitleView: LiquidGlassTitleView { + if let existingView = _liquidGlassTitleView as? LiquidGlassTitleView { + return existingView + } + let newView = LiquidGlassTitleView() + _liquidGlassTitleView = newView + return newView + } + + @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 + } else if progress > 0.99 { + liquidGlassTitleView.textColor = nil + } + } + func threadActionsMenu() -> UIMenu { return UIMenu(title: thread.title ?? "", image: nil, identifier: nil, options: .displayInline, children: [ // Bookmark @@ -96,6 +120,7 @@ final class PostsPageViewController: ViewController { private lazy var postsView: PostsPageView = { let postsView = PostsPageView() + postsView.postsPageViewController = self postsView.didStartRefreshing = { [weak self] in self?.loadNextPageOrRefresh() } @@ -126,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") @@ -133,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 { @@ -183,7 +223,21 @@ 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 + glassView.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) + + navigationItem.titleView = glassView + + configureNavigationBarForLiquidGlass() + } else { + navigationItem.titleView = nil + navigationItem.titleLabel.text = title + } + } } /** @@ -334,7 +388,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 @@ -423,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 }() @@ -514,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 }() @@ -529,9 +598,22 @@ 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 }() + 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 +624,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 +656,42 @@ 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"] + } 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 +717,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 +763,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 +793,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 +847,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 +1198,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 +1635,39 @@ 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 + 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"] + navigationItem.titleLabel.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) - 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"]! + if UIDevice.current.userInterfaceIdiom == .phone { + navigationItem.titleLabel.numberOfLines = 2 + } + + navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"] ?? .label + } + + // 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 +1687,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 +1721,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 immersive mode manager with navigation controller + postsView.immersiveModeManager.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 @@ -1633,12 +1822,109 @@ final class PostsPageViewController: ViewController { 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 } + 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 + } + + // Start with opaque background - NavigationController will handle the transition to clear on scroll + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme["navigationBarTintColor"] + appearance.shadowColor = nil + appearance.shadowImage = nil + + let textColor: UIColor = theme["navigationBarTextColor"] ?? .label + 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 + + // 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() + navigationBar.layoutIfNeeded() + + if let previousVC = navigationController?.viewControllers.dropLast().last { + previousVC.navigationItem.backBarButtonItem?.tintColor = navTextColor + } + + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) configureUserActivityIfPossible() } + func exitImmersiveMode() { + postsView.immersiveModeManager.exitImmersiveMode() + resetNavigationBarState(animated: false) + } + + 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: animated) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + exitImmersiveMode() + } + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) diff --git a/App/Views/LiquidGlassTitleView.swift b/App/Views/LiquidGlassTitleView.swift new file mode 100644 index 000000000..37af8f95b --- /dev/null +++ b/App/Views/LiquidGlassTitleView.swift @@ -0,0 +1,121 @@ +// LiquidGlassTitleView.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US + +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 + 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 = 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) + } + + 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: 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 + 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: Self.defaultWidth, height: Self.defaultHeight) + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + return CGSize(width: Self.defaultWidth, height: Self.defaultHeight) + } +} diff --git a/App/Views/PageNumberView.swift b/App/Views/PageNumberView.swift new file mode 100644 index 000000000..056823a7e --- /dev/null +++ b/App/Views/PageNumberView.swift @@ -0,0 +1,136 @@ +// PageNumberView.swift +// +// Copyright © 2025 Awful Contributors. All rights reserved. +// + +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 let currentHeight: CGFloat = { + if #available(iOS 26.0, *) { + return heightModern + } else { + return heightLegacy + } + }() + + 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 = .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: Self.minWidth), + heightAnchor.constraint(equalToConstant: Self.currentHeight) + ]) + + 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 + return CGSize(width: max(labelSize.width, Self.minWidth), height: Self.currentHeight) + } + + 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..9bdeaad8b 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -202,6 +202,11 @@ 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 /* ImmersiveModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B482EC876590026826C /* ImmersiveModeManager.swift */; }; + 2D571B4B2EC876A20026826C /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B4A2EC8769E0026826C /* GradientView.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 +522,15 @@ 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 /* ImmersiveModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveModeManager.swift; sourceTree = ""; }; + 2D571B4A2EC8769E0026826C /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.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 +660,7 @@ 1C00E0AB173A006B008895E7 /* Navigation */ = { isa = PBXGroup; children = ( + 2D571B482EC876590026826C /* ImmersiveModeManager.swift */, 1C0D80011CF9FE79003EE2D1 /* NavigationBar.swift */, 1C0D80031CF9FE81003EE2D1 /* NavigationController.swift */, 1C0D7FFF1CF9FE70003EE2D1 /* Toolbar.swift */, @@ -748,6 +758,8 @@ 1C29C382225853A300E1217A /* Posts */ = { isa = PBXGroup; children = ( + 2D571B4E2EC878A40026826C /* PostsPageTopBarLiquidGlass.swift */, + 2D571B4A2EC8769E0026826C /* GradientView.swift */, 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */, 1C16FC191CD42EB300C88BD1 /* PostPreviewViewController.swift */, 1C16FBE61CBC671A00C88BD1 /* PostRenderModel.swift */, @@ -1119,6 +1131,8 @@ 1CF7FACB1BB60C2200A077F2 /* Views */ = { isa = PBXGroup; children = ( + 2D571B522EC87AFE0026826C /* PageNumberView.swift */, + 2D571B502EC879590026826C /* LiquidGlassTitleView.swift */, 2D10A3842E05C35700544F91 /* SearchView.swift */, 1CC22AB419F972C200D5BABD /* HairlineView.swift */, 1C16FC011CC29B2C00C88BD1 /* LoadingView.swift */, @@ -1541,7 +1555,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 +1565,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 +1602,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 +1613,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 /* ImmersiveModeManager.swift in Sources */, 1C0D80001CF9FE70003EE2D1 /* Toolbar.swift in Sources */, 1C16FC101CC6F19700C88BD1 /* ThreadPreviewViewController.swift in Sources */, 1C09BFF01A09D485007C11F5 /* InAppActionCollectionViewLayout.swift in Sources */, @@ -1646,6 +1663,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..95a1442c1 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) + /// 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 ad4d94f76..098988394 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -117,6 +117,9 @@ }, "Imgur Uploads" : { + }, + "Immersive Mode" : { + }, "Links" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index ab34cb906..90a80db67 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.immersiveModeEnabled) private var immersiveModeEnabled @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("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)