Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
580 changes: 580 additions & 0 deletions App/Navigation/ImmersiveModeManager.swift

Large diffs are not rendered by default.

57 changes: 45 additions & 12 deletions App/Navigation/NavigationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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, *) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand Down
65 changes: 64 additions & 1 deletion App/View Controllers/Posts/PostsPageSettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading