diff --git a/App/Navigation/ImmersiveModeManager.swift b/App/Navigation/ImmersiveModeManager.swift new file mode 100644 index 000000000..670451e47 --- /dev/null +++ b/App/Navigation/ImmersiveModeManager.swift @@ -0,0 +1,580 @@ +// 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 + +/// Hides and shows the navigation bar, top-bar container, and toolbar in +/// response to scrolling on the posts page. Bars slide off-screen during +/// normal scroll (transform-driven) and fade in via alpha when the user +/// nears the bottom of the webview. +@MainActor +final class ImmersiveModeManager: NSObject { + + // MARK: - Constants + + /// Extra upward travel beyond the nav bar height so it fully clears the status bar + /// and doesn't peek when the user scrolls near the top. + private static let navBarHideOvertravel: CGFloat = 30 + /// Extra downward travel so the toolbar fully clears the bottom safe area + /// and doesn't leave a few pixels peeking at the bottom of the screen. + private static let toolbarHideOvertravel: CGFloat = 20 + 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 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 + + @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 + + private var shouldProcessScroll: Bool { + immersiveModeEnabled + && !UIAccessibility.isVoiceOverRunning + } + + /// 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: when the user nears the bottom of the webview we + /// reveal the bars via alpha (0 → 1) instead of the slide transform, so + /// the final reveal doesn't snap while the scroll view is settling. + private var isInBottomFadeMode = false + private var bottomFadeProgress: CGFloat = 0.0 + + /// True while `animateIntoBottomFade()`'s UIView.animate is still running. + /// `updateBarsForBottomFade` checks this so later scroll ticks (e.g. from + /// `snapToVisibleIfAtBottom` on `didEndDecelerating`) don't cancel the + /// in-flight CAAnimation by hammering alpha to 1 via a disabled-actions + /// transaction. + private var isFadingIntoBottom = false + + // MARK: - UI Elements + + lazy var safeAreaGradientView: GradientView = { + let view = GradientView() + view.isUserInteractionEnabled = false + view.alpha = 0.0 + return view + }() + + // MARK: - Computed Properties + + 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 bottomDistance = toolbarHeight + postsView.effectiveBottomInset + + if let navBar = findNavigationBar() { + let navBarHeight = navBar.bounds.height + let deviceSafeAreaTop = window.safeAreaInsets.top + let topDistance = navBarHeight + deviceSafeAreaTop + Self.navBarHideOvertravel + cachedTotalBarTravelDistance = max(bottomDistance, topDistance) + } else { + cachedTotalBarTravelDistance = bottomDistance + } + + return cachedTotalBarTravelDistance ?? 100 + } + + 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) + } + + 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-reveal bars (e.g. when leaving the posts view). + 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 + } + + /// Bottom scroll inset to use while immersive mode is enabled. Computes the static + /// (pre-transform) toolbar position so insets stay stable as the toolbar slides away. + /// - Parameter fallbackInset: Value to return when refs are unavailable. + func calculateBottomInset(fallbackInset: CGFloat) -> CGFloat { + guard immersiveModeEnabled, + let toolbar = toolbar, + let postsView = postsView else { + return fallbackInset + } + + return postsView.effectiveBottomInset + toolbar.bounds.height + } + + 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 + ) + } + + func reapplyTransformsAfterLayout() { + // Layout may have changed bar sizes, so invalidate the cached distance. + 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) + } + + func handleScrollViewDidScroll( + _ scrollView: UIScrollView, + isDragging: Bool, + isDecelerating: Bool, + isRefreshControlArmedOrTriggered: Bool + ) { + guard shouldProcessScroll, + !isRefreshControlArmedOrTriggered else { return } + + // Check for bottom proximity even when not actively scrolling so the + // reveal fires during momentum scrolling too. + 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 + animateIntoBottomFade() + } + lastScrollOffset = scrollView.contentOffset.y + return + } + + 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 + } + + 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() + } + } + + // Ignore tiny scroll deltas so tap-to-stop and minor rubber-band wobble + // don't tick progress forward. + guard abs(scrollDelta) > Self.minScrollDelta else { + return + } + + let incrementalProgress = immersiveProgress + (scrollDelta / barTravelDistance) + immersiveProgress = incrementalProgress.clamp(0...1) + + lastScrollOffset = currentOffset + } + + // MARK: - Private Methods + + /// Drives the slide transforms during normal scroll. Wrapped in a + /// disabled-actions transaction so implicit CA animations don't lag the + /// transforms behind the scroll gesture. + 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) + defer { CATransaction.commit() } + + guard immersiveModeEnabled && immersiveProgress > 0 else { + safeAreaGradientView.alpha = 0.0 + resetAllTransforms() + 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) + } + } + + /// Fades bars back in when entering bottom fade mode. Snaps to + /// "invisible at natural position" first (alphas to 0, transforms to + /// identity, implicit actions suppressed) then animates alpha to 1 on + /// the next runloop tick so the render server has observed the 0-alpha + /// state before the CAAnimation starts interpolating from it. + private func animateIntoBottomFade() { + guard !isUpdatingBars else { return } + isUpdatingBars = true + defer { isUpdatingBars = false } + + let navBar = findNavigationBar() + + CATransaction.begin() + CATransaction.setDisableActions(true) + navBar?.alpha = 0 + topBarContainer?.alpha = 0 + toolbar?.alpha = 0 + resetAllTransforms() + safeAreaGradientView.alpha = 0.0 + CATransaction.commit() + + // `isFadingIntoBottom` blocks concurrent `updateBarsForBottomFade` + // calls (e.g. from `didEndDecelerating`) that would otherwise snap + // alpha to 1 mid-animation and cancel the CAAnimation. + isFadingIntoBottom = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + UIView.animate( + withDuration: 0.3, + delay: 0, + options: [.allowUserInteraction], + animations: { + navBar?.alpha = 1 + self.topBarContainer?.alpha = 1 + self.toolbar?.alpha = 1 + }, + completion: { [weak self] _ in + self?.isFadingIntoBottom = false + } + ) + } + } + + /// Scroll-driven alpha update while in bottom fade mode. Used for the + /// fade-out as the user scrolls away from the bottom; the fade-in entry + /// is handled by `animateIntoBottomFade()`. + private func updateBarsForBottomFade() { + guard !isUpdatingBars else { return } + + // Don't clobber the entry fade-in animation with a direct alpha=1 + // write. + if isFadingIntoBottom && bottomFadeProgress >= 1.0 { + return + } + + isUpdatingBars = true + defer { isUpdatingBars = false } + + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + + resetAllTransforms() + + let alpha = bottomFadeProgress + + if let navBar = findNavigationBar() { + navBar.alpha = alpha + } + topBarContainer?.alpha = alpha + toolbar?.alpha = alpha + + // Gradient is only shown when bars are hidden via slide. + safeAreaGradientView.alpha = 0.0 + } + + /// Leaves bottom fade mode and hands visibility back to the slide transforms. + private func exitBottomFadeMode() { + guard isInBottomFadeMode else { return } + + isInBottomFadeMode = false + bottomFadeProgress = 0.0 + + // Restore alpha to 1 so the transform (not alpha) drives visibility. + restoreBarAlphas() + + _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.navBarHideOvertravel + return -totalUpwardDistance * immersiveProgress + } + + private func calculateToolbarTransform() -> CGFloat { + guard let toolbar = toolbar else { return 0 } + + let toolbarHeight = toolbar.bounds.height + Self.toolbarHideOvertravel + // Match the inset the toolbar was laid out with — on iOS 26+ iPad this + // can be larger than the system safe area (see `effectiveBottomInset`), + // and translating by just the safe area would leave the top of the + // toolbar visible. + let bottomInset = postsView?.effectiveBottomInset ?? postsView?.window?.safeAreaInsets.bottom ?? 34 + let totalDownwardDistance = toolbarHeight + bottomInset + 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 + } + + /// Remaining scroll distance to the effective bottom of content. Handles + /// content shorter than the scroll view (uses bounds height as the floor) + /// and accounts for the adjusted bottom inset (typically the toolbar). + 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 distanceFromBottom <= bottomFadeZone { + let wasInBottomFadeMode = isInBottomFadeMode + isInBottomFadeMode = true + bottomFadeProgress = 1.0 + if wasInBottomFadeMode { + updateBarsForBottomFade() + } else { + animateIntoBottomFade() + } + } + } + + private func findNavigationBar() -> UINavigationBar? { + if let cached = cachedNavigationBar { + return cached + } + + if let navBar = navigationController?.navigationBar { + cachedNavigationBar = navBar + return navBar + } + + 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 + } + + 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 f66c43c13..5085e09e4 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -228,6 +228,27 @@ final class NavigationController: UINavigationController, Themeable { var isScrolledFromTop = false private var lastAppliedScrollProgress: CGFloat = -1 + // MARK: Scroll-driven appearance caches + // + // `updateNavigationBarTintForScrollProgress` rebuilds a UINavigationBarAppearance on + // every scroll delta above 0.005. Cache the expensive pieces so scroll-driven updates + // don't re-allocate/redraw identical resources each frame. + private var cachedGradientImage: UIImage? + private var cachedGradientImageColor: UIColor? + private lazy var cachedBackIndicatorTemplate: UIImage? = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) + private lazy var cachedBackIndicatorLabelTinted: UIImage? = UIImage(named: "back")?.withTintColor(.label, renderingMode: .alwaysOriginal) + + @available(iOS 26.0, *) + private func gradientBackgroundImage(from color: UIColor) -> UIImage? { + if let cached = cachedGradientImage, cachedGradientImageColor == color { + return cached + } + let image = createGradientBackgroundImage(from: color) + cachedGradientImage = image + cachedGradientImageColor = color + return image + } + func statusBarEnterLightBackground() { isDarkContentBackground = false setNeedsStatusBarAppearanceUpdate() @@ -321,6 +342,8 @@ final class NavigationController: UINavigationController, Themeable { func themeDidChange() { lastAppliedScrollProgress = -1 + cachedGradientImage = nil + cachedGradientImageColor = nil updateNavigationBarAppearance(with: theme) if #available(iOS 26.0, *) { @@ -572,13 +595,26 @@ final class NavigationController: UINavigationController, Themeable { let progressValue = CGFloat(progress.floatValue) + // Snap to the extremes: near-0 and near-1 scrolls render identically to 0 and 1, + // so collapse them into a single stable value. Without this, oscillations around + // the boundaries (e.g. 0.003 → 0.009 → 0.002) each clear the 0.005 delta gate + // and rebuild the appearance even though the visual result is unchanged. + let snappedProgress: CGFloat + if progressValue < ScrollProgress.atTop { + snappedProgress = 0 + } else if progressValue > ScrollProgress.fullyScrolled { + snappedProgress = 1 + } else { + snappedProgress = progressValue + } + // Avoid redundant appearance rebuilds when progress hasn't changed. - if abs(progressValue - lastAppliedScrollProgress) < 0.005 { + if abs(snappedProgress - lastAppliedScrollProgress) < 0.005 { return } - lastAppliedScrollProgress = progressValue + lastAppliedScrollProgress = snappedProgress - updateNavigationBarBackgroundWithProgress(progressValue) + updateNavigationBarBackgroundWithProgress(snappedProgress) if progressValue < ScrollProgress.atTop { isScrolledFromTop = false @@ -649,7 +685,7 @@ final class NavigationController: UINavigationController, Themeable { return } - if let gradientImage = createGradientBackgroundImage(from: gradientBaseColor) { + if let gradientImage = gradientBackgroundImage(from: gradientBaseColor) { appearance.backgroundImage = gradientImage let overlayAlpha = 1.0 - progress appearance.backgroundColor = opaqueColor.withAlphaComponent(overlayAlpha) @@ -667,14 +703,11 @@ final class NavigationController: UINavigationController, Themeable { @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) - } + let backImage = progress > ScrollProgress.fullyScrolled + ? cachedBackIndicatorLabelTinted + : cachedBackIndicatorTemplate + if let backImage { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } } diff --git a/App/View Controllers/Posts/PostsPageSettingsViewController.swift b/App/View Controllers/Posts/PostsPageSettingsViewController.swift index b7067bacb..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 @@ -42,7 +43,7 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati performHapticFeedback() showImages = sender.isOn } - + @IBOutlet private var scaleTextLabel: UILabel! @IBOutlet private var scaleTextStepper: UIStepper! @IBAction private func scaleStepperDidChange(_ sender: UIStepper) { @@ -64,8 +65,13 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati 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 @@ -132,6 +138,60 @@ 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() { @@ -153,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/PostsPageTopBarLiquidGlass.swift b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift index ac31eb5b1..c09f44aa3 100644 --- a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift +++ b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift @@ -75,7 +75,7 @@ final class PostsPageTopBarLiquidGlass: UIView, PostsPageTopBarProtocol { private let label: UILabel override init(frame: CGRect) { - let glassEffect = UIGlassEffect() + let glassEffect = UIGlassEffect(style: .clear) glassView = UIVisualEffectView(effect: glassEffect) glassView.translatesAutoresizingMaskIntoConstraints = false glassView.isUserInteractionEnabled = false diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index 3ac0d9b07..0ca6a6cd0 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -17,10 +17,35 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: */ final class PostsPageView: UIView { + /// On iPad / Mac (Designed for iPad) the system safe area at the bottom can be 0 + /// (windowed multitasking, Catalyst). Enforce a minimum so the toolbar aligns with + /// the sidebar's RootTabBar rather than disappearing off the bottom. + static let minimumPadBottomInset: CGFloat = 28 + @FoilDefaultStorage(Settings.darkMode) private var darkMode @FoilDefaultStorage(Settings.frogAndGhostEnabled) private var frogAndGhostEnabled var viewHasBeenScrolledOnce: Bool = false + let immersiveModeManager = ImmersiveModeManager() + + /// Effective bottom inset used to position the toolbar: honors the system safe + /// area on iPhone, and raises the toolbar to `minimumPadBottomInset` on iPad + /// so it aligns with the sidebar's RootTabBar. + /// + /// Gated to iOS 26+ because pre-26 toolbars are opaque — raising the + /// toolbar above the system safe area leaves a gap between the toolbar's + /// bottom edge and the window edge that exposes scroll content behind it. + /// On iOS 26 the glass material and layout read differently, so the + /// sidebar-alignment nudge looks correct there. + var effectiveBottomInset: CGFloat { + guard #available(iOS 26.0, *) else { + return layoutMargins.bottom + } + return traitCollection.userInterfaceIdiom == .pad + ? max(layoutMargins.bottom, Self.minimumPadBottomInset) + : layoutMargins.bottom + } + /// Weak reference to the posts page view controller to avoid responder chain traversal weak var postsPageViewController: PostsPageViewController? @@ -61,17 +86,21 @@ final class PostsPageView: UIView { let containerMargins = refreshControlContainer.layoutMarginsGuide if frogAndGhostEnabled == false { + // Horizontal anchors use PostsPageView's layoutMarginsGuide so the + // arrow centers within the visible detail column on iPad/macOS + // rather than within the full scroll-view width. NSLayoutConstraint.activate([ - refreshControl.leftAnchor.constraint(equalTo: containerMargins.leftAnchor), - containerMargins.rightAnchor.constraint(equalTo: refreshControl.rightAnchor), + refreshControl.leftAnchor.constraint(equalTo: layoutMarginsGuide.leftAnchor), + layoutMarginsGuide.rightAnchor.constraint(equalTo: refreshControl.rightAnchor), refreshControl.topAnchor.constraint(equalTo: containerMargins.topAnchor), containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor)]) } else { if refreshControl is PostsPageRefreshArrowView { + // Same reasoning as the frog spinner below. NSLayoutConstraint.activate([ - refreshControl.leftAnchor.constraint(equalTo: containerMargins.leftAnchor), + refreshControl.leftAnchor.constraint(equalTo: layoutMarginsGuide.leftAnchor), refreshControl.topAnchor.constraint(equalTo: containerMargins.topAnchor), - containerMargins.rightAnchor.constraint(equalTo: refreshControl.rightAnchor), + layoutMarginsGuide.rightAnchor.constraint(equalTo: refreshControl.rightAnchor), containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor) ]) } @@ -86,6 +115,7 @@ final class PostsPageView: UIView { refreshControl.leftAnchor.constraint(equalTo: layoutMarginsGuide.leftAnchor), layoutMarginsGuide.rightAnchor.constraint(equalTo: refreshControl.rightAnchor), containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor), + // controls frog lottie position between last post and bottom toolbar refreshControl.heightAnchor.constraint(equalToConstant: 110) ]) } @@ -245,13 +275,8 @@ final class PostsPageView: UIView { 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 - } + view.alpha = 0.0 + view.isHidden = true return view }() @@ -270,14 +295,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) - addSubview(fallbackSafeAreaGradientView) + 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) } @@ -294,17 +331,12 @@ final class PostsPageView: UIView { width: bounds.width - layoutMargins.left - layoutMargins.right, height: bounds.height) + if #available(iOS 26.0, *) { + immersiveModeManager.updateGradientLayout(in: self) + } + if toolbar.transform == .identity { let toolbarHeight = toolbar.sizeThatFits(bounds.size).height - // On iPad / Mac (Designed for iPad), the system safe area at the - // bottom can be 0 (windowed multitasking, Catalyst), which would - // pin the toolbar flush against the window's bottom edge. Enforce - // a minimum bottom inset so the toolbar reads as aligned with the - // sidebar's RootTabBar instead of disappearing off the bottom. - let minimumPadBottomInset: CGFloat = 28 - let effectiveBottomInset: CGFloat = traitCollection.userInterfaceIdiom == .pad - ? max(layoutMargins.bottom, minimumPadBottomInset) - : layoutMargins.bottom toolbar.frame = CGRect( x: bounds.minX + layoutMargins.left, y: bounds.maxY - effectiveBottomInset - toolbarHeight, @@ -322,12 +354,20 @@ 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 + layoutMargins.left, - y: bounds.minY + layoutMargins.top, + y: topBarY, width: bounds.width - layoutMargins.left - layoutMargins.right, height: topBarHeight) updateTopBarContainerFrameAndScrollViewInsets() + + immersiveModeManager.reapplyTransformsAfterLayout() } /// Assumes that various views (top bar container, refresh control container, toolbar) have been laid out. @@ -352,7 +392,12 @@ final class PostsPageView: UIView { private func calculateBottomInset() -> CGFloat { let normalInset = bounds.maxY - toolbar.frame.minY - return normalInset + + if immersiveModeManager.shouldAdjustScrollInsets() { + return immersiveModeManager.calculateBottomInset(fallbackInset: normalInset) + } else { + return normalInset + } } @objc private func voiceOverStatusDidChange(_ notification: Notification) { @@ -394,6 +439,10 @@ final class PostsPageView: UIView { } topBar.themeDidChange(Theme.defaultTheme()) + + if #available(iOS 26.0, *) { + immersiveModeManager.safeAreaGradientView.themeDidChange() + } } // MARK: Gunk @@ -594,6 +643,8 @@ extension PostsPageView { extension PostsPageView: ScrollViewDelegateExtras { func scrollViewDidChangeContentSize(_ scrollView: UIScrollView) { setNeedsLayout() + + immersiveModeManager.handleScrollViewDidChangeContentSize(scrollView) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { @@ -603,25 +654,37 @@ extension PostsPageView: ScrollViewDelegateExtras { 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 + } } } } @@ -643,6 +706,12 @@ extension PostsPageView: ScrollViewDelegateExtras { if !willDecelerate { updateTopBarDidEndDecelerating() + + immersiveModeManager.handleScrollViewDidEndDragging( + scrollView, + willDecelerate: willDecelerate, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) } willBeginDraggingContentOffset = nil @@ -658,6 +727,11 @@ extension PostsPageView: ScrollViewDelegateExtras { } updateTopBarDidEndDecelerating() + + immersiveModeManager.handleScrollViewDidEndDecelerating( + scrollView, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) } private func updateTopBarDidEndDecelerating() { @@ -715,6 +789,13 @@ extension PostsPageView: ScrollViewDelegateExtras { break } + immersiveModeManager.handleScrollViewDidScroll( + scrollView, + isDragging: scrollView.isDragging, + isDecelerating: scrollView.isDecelerating, + isRefreshControlArmedOrTriggered: refreshControlState.isArmedOrTriggered + ) + switch topBarState { case .appearing, .disappearing: updateTopBarContainerFrameAndScrollViewInsets() diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index a5c30926f..ea7bf4c16 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -596,6 +596,9 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Settings" + if #unavailable(iOS 26.0) { + item.tintColor = theme["toolbarTextColor"] + } return item }() @@ -611,6 +614,9 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Previous page" + if #unavailable(iOS 26.0) { + item.tintColor = theme["toolbarTextColor"] + } return item }() @@ -664,6 +670,9 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Next page" + if #unavailable(iOS 26.0) { + item.tintColor = theme["toolbarTextColor"] + } return item }() @@ -675,7 +684,7 @@ final class PostsPageViewController: ViewController { 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 { @@ -689,6 +698,9 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Thread actions" + if #unavailable(iOS 26.0) { + item.tintColor = theme["toolbarTextColor"] + } return item } @@ -1749,12 +1761,15 @@ final class PostsPageViewController: ViewController { } let appearance = UIToolbarAppearance() - if (postsView.toolbar.isTranslucent) { + if #available(iOS 26.0, *), postsView.toolbar.isTranslucent { appearance.configureWithDefaultBackground() } else { + // Force opaque on iOS <26. Otherwise the toolbar renders + // translucent on iPad iOS 18 and post content bleeds through. + postsView.toolbar.isTranslucent = false appearance.configureWithOpaqueBackground() } - appearance.backgroundColor = Theme.defaultTheme()["backgroundColor"]! + appearance.backgroundColor = Theme.defaultTheme()["backgroundColor"] appearance.shadowImage = nil appearance.shadowColor = nil @@ -1794,6 +1809,14 @@ final class PostsPageViewController: ViewController { postsView.renderView.scrollView.contentInsetAdjustmentBehavior = .never view.addSubview(postsView, constrainEdges: .all) + 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 postsView.renderView.addGestureRecognizer(longPress) @@ -1966,6 +1989,11 @@ final class PostsPageViewController: ViewController { configureUserActivityIfPossible() } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + postsView.immersiveModeManager.exitImmersiveMode() + } + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) diff --git a/App/View Controllers/RootTabBarController.swift b/App/View Controllers/RootTabBarController.swift index a80c1b6c8..0eca3c152 100644 --- a/App/View Controllers/RootTabBarController.swift +++ b/App/View Controllers/RootTabBarController.swift @@ -86,8 +86,8 @@ final class RootTabBarController: UITabBarController, UITabBarControllerDelegate tabBar.standardAppearance = barAppearance } else { barAppearance.configureWithOpaqueBackground() - barAppearance.backgroundColor = theme[uicolor: "tabBarBackgroundColor"]! - barAppearance.shadowColor = theme[uicolor: "bottomBarTopBorderColor"]! + barAppearance.backgroundColor = theme[uicolor: "tabBarBackgroundColor"] + barAppearance.shadowColor = theme[uicolor: "bottomBarTopBorderColor"] tabBar.isTranslucent = false tabBar.barTintColor = theme["tabBarBackgroundColor"] diff --git a/App/Views/RenderView.swift b/App/Views/RenderView.swift index 3068b1cf5..8a1b04533 100644 --- a/App/Views/RenderView.swift +++ b/App/Views/RenderView.swift @@ -645,7 +645,7 @@ extension RenderView { } } } - + /// Turns each link with a `data-awful-linkified-image` attribute into a a proper `img` element. func loadLinkifiedImages() { Task { diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index 86faaaa30..676f0e473 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -222,6 +222,7 @@ 2D3D26012F85E80100862513 /* NewThreadDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D26002F85E80100862513 /* NewThreadDraft.swift */; }; 2D3D26032F85E80100862514 /* PrivateMessageDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D26022F85E80100862514 /* PrivateMessageDraft.swift */; }; 2D571B472EC83DD00026826C /* AttachmentCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B462EC83DD00026826C /* AttachmentCardView.swift */; }; + 2D571B492EC8765F0026826C /* ImmersiveModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B482EC876590026826C /* ImmersiveModeManager.swift */; }; 2D62DEA42EBFE93800F7121B /* LiquidGlassTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */; }; 2D62DEA62EBFE95B00F7121B /* PostsPageTopBarLiquidGlass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA52EBFE95500F7121B /* PostsPageTopBarLiquidGlass.swift */; }; 2D62DEA82EBFEB2000F7121B /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA72EBFEB1D00F7121B /* GradientView.swift */; }; @@ -566,6 +567,7 @@ 2D3D26002F85E80100862513 /* NewThreadDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewThreadDraft.swift; sourceTree = ""; }; 2D3D26022F85E80100862514 /* PrivateMessageDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateMessageDraft.swift; sourceTree = ""; }; 2D571B462EC83DD00026826C /* AttachmentCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentCardView.swift; sourceTree = ""; }; + 2D571B482EC876590026826C /* ImmersiveModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveModeManager.swift; sourceTree = ""; }; 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassTitleView.swift; sourceTree = ""; }; 2D62DEA52EBFE95500F7121B /* PostsPageTopBarLiquidGlass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsPageTopBarLiquidGlass.swift; sourceTree = ""; }; 2D62DEA72EBFEB1D00F7121B /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; @@ -710,6 +712,7 @@ 1C00E0AB173A006B008895E7 /* Navigation */ = { isa = PBXGroup; children = ( + 2D571B482EC876590026826C /* ImmersiveModeManager.swift */, 1C0D80011CF9FE79003EE2D1 /* NavigationBar.swift */, 1C0D80031CF9FE81003EE2D1 /* NavigationController.swift */, 1C0D7FFF1CF9FE70003EE2D1 /* Toolbar.swift */, @@ -1758,6 +1761,7 @@ 1C8F680B222B8F06007E61ED /* NamedThreadTag.swift in Sources */, 1C24BC98200A9BE00022C85F /* ThreadListDataSource.swift in Sources */, 1C0D80041CF9FE81003EE2D1 /* NavigationController.swift in Sources */, + 2D571B492EC8765F0026826C /* ImmersiveModeManager.swift in Sources */, 1C16FBB21CB86ACD00C88BD1 /* EmptyViewController.swift in Sources */, 1C353C071E416FE200CCBA51 /* SpriteSheetView.swift in Sources */, 1C16FBA21CB49D2700C88BD1 /* SelfHostingAttachmentInterpolator.swift in Sources */, diff --git a/AwfulSettings/Sources/AwfulSettings/Settings.swift b/AwfulSettings/Sources/AwfulSettings/Settings.swift index 658defc3d..3183e80cf 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 af155a65e..67d9aeae0 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -120,6 +120,9 @@ }, "Imgur Uploads" : { + }, + "Immersive Mode" : { + }, "Links" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index 6a91acd98..091725bad 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 @@ -149,6 +150,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)