diff --git a/App/Composition/CompositionViewController.swift b/App/Composition/CompositionViewController.swift index 93f0d23ae..6482b090d 100644 --- a/App/Composition/CompositionViewController.swift +++ b/App/Composition/CompositionViewController.swift @@ -77,7 +77,14 @@ final class CompositionViewController: ViewController { // Leave an escape hatch in case we were restored without an associated workspace. This can happen when a crash leaves old state information behind. if navigationItem.leftBarButtonItem == nil { - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(CompositionViewController.didTapCancel)) + let cancelButton = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(CompositionViewController.didTapCancel)) + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + // Let iOS 26+ handle the color automatically + } else { + cancelButton.tintColor = theme["navigationBarTextColor"] + } + navigationItem.leftBarButtonItem = cancelButton } } diff --git a/App/Navigation/NavigationBar.swift b/App/Navigation/NavigationBar.swift index 0461a8974..7adaa43e6 100644 --- a/App/Navigation/NavigationBar.swift +++ b/App/Navigation/NavigationBar.swift @@ -23,22 +23,22 @@ final class NavigationBar: UINavigationBar { super.init(frame: frame) // For whatever reason, translucent navbars with a barTintColor do not necessarily blur their backgrounds. An iPad 3, for example, blurs a bar without a barTintColor but is simply semitransparent with a barTintColor. The semitransparent, non-blur effect looks awful, so just turn it off. - isTranslucent = false - + // iOS 26: Allow translucency for liquid glass effect + if #available(iOS 26.0, *) { + isTranslucent = true + } else { + isTranslucent = false + } + // Setting the barStyle to UIBarStyleBlack results in an appropriate status bar style. barStyle = .black - backIndicatorImage = UIImage(named: "back") - backIndicatorTransitionMaskImage = UIImage(named: "back") + backIndicatorImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) + backIndicatorTransitionMaskImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) titleTextAttributes = [.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular)] addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(didLongPress))) - - if #available(iOS 15.0, *) { - // Fix odd grey navigation bar background when scrolled to top on iOS 15. - scrollEdgeAppearance = standardAppearance - } } required init?(coder: NSCoder) { diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index 652dfc0aa..dfd93a0d5 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/Posts/ReplyWorkspace.swift b/App/Posts/ReplyWorkspace.swift index c25a17882..665fc3112 100644 --- a/App/Posts/ReplyWorkspace.swift +++ b/App/Posts/ReplyWorkspace.swift @@ -118,7 +118,14 @@ final class ReplyWorkspace: NSObject { } let navigationItem = compositionViewController.navigationItem - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ReplyWorkspace.didTapCancel(_:))) + let cancelButton = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(ReplyWorkspace.didTapCancel(_:))) + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + // Let iOS 26+ handle the color automatically + } else { + cancelButton.tintColor = compositionViewController.theme["navigationBarTextColor"] + } + navigationItem.leftBarButtonItem = cancelButton navigationItem.rightBarButtonItem = rightButtonItem $confirmBeforeReplying @@ -139,7 +146,7 @@ final class ReplyWorkspace: NSObject { fileprivate var textViewNotificationToken: AnyObject? fileprivate lazy var rightButtonItem: UIBarButtonItem = { [unowned self] in - return UIBarButtonItem(title: self.draft.submitButtonTitle, style: .done, target: self, action: #selector(ReplyWorkspace.didTapPost(_:))) + return UIBarButtonItem(title: self.draft.submitButtonTitle, style: .plain, target: self, action: #selector(ReplyWorkspace.didTapPost(_:))) }() fileprivate func updateRightButtonItem() { @@ -199,7 +206,14 @@ final class ReplyWorkspace: NSObject { } else { preview = PostPreviewViewController(thread: draft.thread, BBcode: draft.text ?? .init()) } - preview.navigationItem.rightBarButtonItem = UIBarButtonItem(title: draft.submitButtonTitle, style: .done, target: self, action: #selector(ReplyWorkspace.didTapPost(_:))) + let postButton = UIBarButtonItem(title: draft.submitButtonTitle, style: .plain, target: self, action: #selector(ReplyWorkspace.didTapPost(_:))) + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + // Let iOS 26+ handle the color automatically + } else { + postButton.tintColor = compositionViewController.theme["navigationBarTextColor"] + } + preview.navigationItem.rightBarButtonItem = postButton (viewController as! UINavigationController).pushViewController(preview, animated: true) } diff --git a/App/View Controllers/Messages/MessageViewController.swift b/App/View Controllers/Messages/MessageViewController.swift index 2a3e889f9..7fcaffecd 100644 --- a/App/View Controllers/Messages/MessageViewController.swift +++ b/App/View Controllers/Messages/MessageViewController.swift @@ -37,6 +37,18 @@ final class MessageViewController: ViewController { renderView.delegate = self return renderView }() + + private var _liquidGlassTitleView: UIView? + + @available(iOS 26.0, *) + private var liquidGlassTitleView: LiquidGlassTitleView { + if _liquidGlassTitleView == nil { + let titleView = LiquidGlassTitleView() + titleView.title = privateMessage.subject + _liquidGlassTitleView = titleView + } + return _liquidGlassTitleView as! LiquidGlassTitleView + } private lazy var replyButtonItem: UIBarButtonItem = { return UIBarButtonItem(image: UIImage(named: "reply"), style: .plain, target: self, action: #selector(didTapReplyButtonItem)) @@ -55,7 +67,13 @@ final class MessageViewController: ViewController { } override var title: String? { - didSet { navigationItem.titleLabel.text = title } + didSet { + if #available(iOS 26.0, *) { + liquidGlassTitleView.title = title + } else { + navigationItem.titleLabel.text = title + } + } } private func renderMessage() { @@ -194,10 +212,19 @@ final class MessageViewController: ViewController { override func viewDidLoad() { super.viewDidLoad() - + + extendedLayoutIncludesOpaqueBars = true + renderView.frame = CGRect(origin: .zero, size: view.bounds.size) renderView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + renderView.scrollView.contentInsetAdjustmentBehavior = .never + renderView.scrollView.delegate = self view.insertSubview(renderView, at: 0) + + if #available(iOS 26.0, *) { + configureNavigationBarForLiquidGlass() + configureLiquidGlassTitleView() + } renderView.registerMessage(RenderView.BuiltInMessage.DidTapAuthorHeader.self) renderView.registerMessage(RenderView.BuiltInMessage.DidFinishLoadingTweets.self) @@ -286,8 +313,24 @@ final class MessageViewController: ViewController { } loadingView?.tintColor = theme["backgroundColor"] + + if #available(iOS 26.0, *) { + if renderView.scrollView.contentOffset.y <= -renderView.scrollView.adjustedContentInset.top { + liquidGlassTitleView.textColor = theme["navigationBarTextColor"] + } + } } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if #available(iOS 26.0, *) { + if let navController = navigationController as? NavigationController { + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: 0.0)) + } + } + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -299,6 +342,94 @@ final class MessageViewController: ViewController { userActivity = nil } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateScrollViewContentInsets() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + updateScrollViewContentInsets() + } + + private func updateScrollViewContentInsets() { + renderView.scrollView.contentInset.top = view.safeAreaInsets.top + renderView.scrollView.contentInset.bottom = view.safeAreaInsets.bottom + renderView.scrollView.scrollIndicatorInsets = renderView.scrollView.contentInset + } + + @available(iOS 26.0, *) + private func configureNavigationBarForLiquidGlass() { + guard let navigationBar = navigationController?.navigationBar else { return } + guard let navController = navigationController as? NavigationController else { return } + + if let awfulNavigationBar = navigationBar as? NavigationBar { + awfulNavigationBar.bottomBorderColor = .clear + } + + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme["navigationBarTintColor"] + appearance.shadowColor = nil + appearance.shadowImage = nil + + let textColor: UIColor = theme["navigationBarTextColor"]! + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + let buttonAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColor, + .font: buttonFont + ] + appearance.buttonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.buttonAppearance.highlighted.titleTextAttributes = buttonAttributes + appearance.doneButtonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.doneButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + appearance.backButtonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.backButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.compactScrollEdgeAppearance = appearance + + navigationBar.tintColor = textColor + + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: 0.0)) + + navigationBar.setNeedsLayout() + } + + @available(iOS 26.0, *) + private func configureLiquidGlassTitleView() { + liquidGlassTitleView.textColor = theme["navigationBarTextColor"] + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + liquidGlassTitleView.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: 0, weight: .semibold) + default: + liquidGlassTitleView.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: 0, weight: .semibold) + } + + navigationItem.titleView = liquidGlassTitleView + } + + @available(iOS 26.0, *) + private func updateTitleViewTextColorForScrollProgress(_ progress: CGFloat) { + if progress < 0.01 { + liquidGlassTitleView.textColor = theme["navigationBarTextColor"] + } else if progress > 0.99 { + liquidGlassTitleView.textColor = nil + } + } private enum CodingKey { static let composeViewController = "AwfulComposeViewController" @@ -397,6 +528,34 @@ extension MessageViewController: UIGestureRecognizerDelegate { } } +extension MessageViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if #available(iOS 26.0, *) { + let topInset = scrollView.adjustedContentInset.top + let currentOffset = scrollView.contentOffset.y + let topPosition = -topInset + + let transitionDistance: CGFloat = 30.0 + + let progress: CGFloat + if currentOffset <= topPosition { + progress = 0.0 + } else if currentOffset >= topPosition + transitionDistance { + progress = 1.0 + } else { + let distanceFromTop = currentOffset - topPosition + progress = distanceFromTop / transitionDistance + } + + if let navController = navigationController as? NavigationController { + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) + } + + updateTitleViewTextColorForScrollProgress(progress) + } + } +} + extension MessageViewController: UIViewControllerRestoration { static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { guard let messageKey = coder.decodeObject(of: PrivateMessageKey.self, forKey: CodingKey.message) else { 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/PostsPageRefreshSpinnerView.swift b/App/View Controllers/Posts/PostsPageRefreshSpinnerView.swift index aab3bc0ff..0022ab5d0 100644 --- a/App/View Controllers/Posts/PostsPageRefreshSpinnerView.swift +++ b/App/View Controllers/Posts/PostsPageRefreshSpinnerView.swift @@ -17,6 +17,7 @@ final class PostsPageRefreshSpinnerView: UIView, PostsPageRefreshControlContent arrows.topAnchor.constraint(equalTo: topAnchor).isActive = true arrows.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true arrows.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true + alpha = 0.0 } required init?(coder: NSCoder) { @@ -90,6 +91,27 @@ final class PostsPageRefreshSpinnerView: UIView, PostsPageRefreshControlContent var state: PostsPageView.RefreshControlState = .ready { didSet { transition(from: oldValue, to: state) + + switch state { + case .ready, .disabled: + UIView.animate(withDuration: 0.2) { + self.alpha = 0.0 + } + + case .armed(let triggeredFraction): + let targetAlpha = min(1.0, triggeredFraction * 2) + UIView.animate(withDuration: 0.1) { + self.alpha = targetAlpha + } + + case .triggered, .refreshing: + UIView.animate(withDuration: 0.2) { + self.alpha = 1.0 + } + + case .awaitingScrollEnd: + break + } } } } diff --git a/App/View Controllers/Posts/PostsPageSettingsViewController.swift b/App/View Controllers/Posts/PostsPageSettingsViewController.swift index 81b8cce55..b7067bacb 100644 --- a/App/View Controllers/Posts/PostsPageSettingsViewController.swift +++ b/App/View Controllers/Posts/PostsPageSettingsViewController.swift @@ -33,34 +33,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 +60,20 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati @IBOutlet private var darkModeLabel: UILabel! @IBOutlet private var darkModeSwitch: UISwitch! @IBAction func toggleDarkMode(_ sender: UISwitch) { + performHapticFeedback() + darkMode = sender.isOn + } + + @objc private func toggleImmersiveMode(_ sender: UISwitch) { + performHapticFeedback() + } + + // MARK: - Helper Methods + + private func performHapticFeedback() { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - darkMode = sender.isOn } private lazy var fontScaleFormatter: NumberFormatter = { @@ -118,6 +120,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 } @@ -131,7 +136,7 @@ final class PostsPageSettingsViewController: ViewController, UIPopoverPresentati 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() { 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..15ce3cfc7 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -20,6 +20,9 @@ final class PostsPageView: UIView { @FoilDefaultStorage(Settings.darkMode) private var darkMode @FoilDefaultStorage(Settings.frogAndGhostEnabled) private var frogAndGhostEnabled var viewHasBeenScrolledOnce: Bool = false + + /// Weak reference to the posts page view controller to avoid responder chain traversal + weak var postsPageViewController: PostsPageViewController? // MARK: Loading view @@ -64,7 +67,6 @@ final class PostsPageView: UIView { refreshControl.topAnchor.constraint(equalTo: containerMargins.topAnchor), containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor)]) } else { - // arrow view is hidden behind the toolbar and revealed when pulled up if refreshControl is PostsPageRefreshArrowView { NSLayoutConstraint.activate([ refreshControl.leftAnchor.constraint(equalTo: containerMargins.leftAnchor), @@ -73,7 +75,6 @@ final class PostsPageView: UIView { containerMargins.bottomAnchor.constraint(equalTo: refreshControl.bottomAnchor) ]) } - // spinner view is visible above the toolbar, before any scroll triggers occur if refreshControl is GetOutFrogRefreshSpinnerView { NSLayoutConstraint.activate([ refreshControl.leftAnchor.constraint(equalTo: containerMargins.leftAnchor), @@ -174,11 +175,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 +235,19 @@ 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 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 } @@ -233,6 +265,7 @@ final class PostsPageView: UIView { toolbar.overrideUserInterfaceStyle = Theme.defaultTheme()["mode"] == "light" ? .light : .dark addSubview(renderView) + addSubview(fallbackSafeAreaGradientView) addSubview(topBarContainer) addSubview(loadingViewContainer) addSubview(toolbar) @@ -250,12 +283,14 @@ 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 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 @@ -276,22 +311,30 @@ final class PostsPageView: UIView { } /// 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 + return normalInset + } + @objc private func voiceOverStatusDidChange(_ notification: Notification) { if UIAccessibility.isVoiceOverRunning { topBarState = .alwaysVisible @@ -322,9 +365,13 @@ 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()) } @@ -343,11 +390,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 +415,10 @@ extension PostsPageView { clipsToBounds = true + if #available(iOS 26.0, *) { + backgroundColor = .clear + } + addSubview(topBar, constrainEdges: [.bottom, .left, .right]) } @@ -458,6 +522,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 { @@ -504,8 +578,7 @@ extension PostsPageView: ScrollViewDelegateExtras { 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 @@ -513,6 +586,7 @@ extension PostsPageView: ScrollViewDelegateExtras { } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + switch refreshControlState { case .armed, .triggered: // Top bar shouldn't fight with refresh control. @@ -581,6 +655,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. @@ -642,5 +725,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..f3728a27e 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,12 +1721,6 @@ 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()] - let longPress = UILongPressGestureRecognizer(target: self, action: #selector(didLongPressOnPostsView)) longPress.delegate = self postsView.renderView.addGestureRecognizer(longPress) @@ -1633,6 +1812,81 @@ 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) diff --git a/App/View Controllers/ProfileViewController.swift b/App/View Controllers/ProfileViewController.swift index 3fb6da506..41398c3d0 100644 --- a/App/View Controllers/ProfileViewController.swift +++ b/App/View Controllers/ProfileViewController.swift @@ -26,6 +26,18 @@ final class ProfileViewController: ViewController { return renderView }() + private var _liquidGlassTitleView: UIView? + + @available(iOS 26.0, *) + private var liquidGlassTitleView: LiquidGlassTitleView { + if _liquidGlassTitleView == nil { + let titleView = LiquidGlassTitleView() + titleView.title = user.username + _liquidGlassTitleView = titleView + } + return _liquidGlassTitleView as! LiquidGlassTitleView + } + private var user: User { didSet { updateTitle() } } @@ -41,6 +53,9 @@ final class ProfileViewController: ViewController { private func updateTitle() { title = user.username ?? LocalizedString("profile.default-title") + if #available(iOS 26.0, *) { + liquidGlassTitleView.title = title + } } private func sendPrivateMessage() { @@ -57,23 +72,44 @@ final class ProfileViewController: ViewController { } override func viewDidLoad() { + super.viewDidLoad() + + extendedLayoutIncludesOpaqueBars = true + view.addSubview(renderView) renderView.translatesAutoresizingMaskIntoConstraints = true renderView.frame = CGRect(origin: .zero, size: view.bounds.size) renderView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - super.viewDidLoad() + renderView.scrollView.contentInsetAdjustmentBehavior = .never + renderView.scrollView.delegate = self + + if #available(iOS 26.0, *) { + configureNavigationBarForLiquidGlass() + configureLiquidGlassTitleView() + } } override func themeDidChange() { super.themeDidChange() renderProfile() + + if #available(iOS 26.0, *) { + if renderView.scrollView.contentOffset.y <= -renderView.scrollView.adjustedContentInset.top { + liquidGlassTitleView.textColor = theme["navigationBarTextColor"] + } + } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + + if #available(iOS 26.0, *) { + if let navController = navigationController as? NavigationController { + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: 0.0)) + } + } + if presentingViewController != nil && navigationController?.viewControllers.count == 1 { navigationItem.leftBarButtonItem = .init(systemItem: .done, primaryAction: UIAction { _ in self.dismiss(animated: true, completion: nil) @@ -101,6 +137,94 @@ final class ProfileViewController: ViewController { renderView.scrollView.flashScrollIndicators() } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateScrollViewContentInsets() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + updateScrollViewContentInsets() + } + + private func updateScrollViewContentInsets() { + renderView.scrollView.contentInset.top = view.safeAreaInsets.top + renderView.scrollView.contentInset.bottom = view.safeAreaInsets.bottom + renderView.scrollView.scrollIndicatorInsets = renderView.scrollView.contentInset + } + + @available(iOS 26.0, *) + private func configureNavigationBarForLiquidGlass() { + guard let navigationBar = navigationController?.navigationBar else { return } + guard let navController = navigationController as? NavigationController else { return } + + if let awfulNavigationBar = navigationBar as? NavigationBar { + awfulNavigationBar.bottomBorderColor = .clear + } + + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme["navigationBarTintColor"] + appearance.shadowColor = nil + appearance.shadowImage = nil + + let textColor: UIColor = theme["navigationBarTextColor"]! + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + let buttonAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColor, + .font: buttonFont + ] + appearance.buttonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.buttonAppearance.highlighted.titleTextAttributes = buttonAttributes + appearance.doneButtonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.doneButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + appearance.backButtonAppearance.normal.titleTextAttributes = buttonAttributes + appearance.backButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.compactScrollEdgeAppearance = appearance + + navigationBar.tintColor = textColor + + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: 0.0)) + + navigationBar.setNeedsLayout() + } + + @available(iOS 26.0, *) + private func configureLiquidGlassTitleView() { + liquidGlassTitleView.textColor = theme["navigationBarTextColor"] + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + liquidGlassTitleView.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: 0, weight: .semibold) + default: + liquidGlassTitleView.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: 0, weight: .semibold) + } + + navigationItem.titleView = liquidGlassTitleView + } + + @available(iOS 26.0, *) + private func updateTitleViewTextColorForScrollProgress(_ progress: CGFloat) { + if progress < 0.01 { + liquidGlassTitleView.textColor = theme["navigationBarTextColor"] + } else if progress > 0.99 { + liquidGlassTitleView.textColor = nil + } + } private func renderProfile() { let html: String = { @@ -158,6 +282,34 @@ private struct ShowHomepageActions: RenderViewMessage { } } +extension ProfileViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if #available(iOS 26.0, *) { + let topInset = scrollView.adjustedContentInset.top + let currentOffset = scrollView.contentOffset.y + let topPosition = -topInset + + let transitionDistance: CGFloat = 30.0 + + let progress: CGFloat + if currentOffset <= topPosition { + progress = 0.0 + } else if currentOffset >= topPosition + transitionDistance { + progress = 1.0 + } else { + let distanceFromTop = currentOffset - topPosition + progress = distanceFromTop / transitionDistance + } + + if let navController = navigationController as? NavigationController { + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) + } + + updateTitleViewTextColorForScrollProgress(progress) + } + } +} + extension ProfileViewController: RenderViewDelegate { func didFinishRenderingHTML(in view: RenderView) { // nop 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/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index b32003490..444fbd5a3 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -202,7 +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 */; }; + 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 */; }; 2D921269292F588100B16011 /* platinum-member.png in Resources */ = {isa = PBXBuildFile; fileRef = 2D921268292F588100B16011 /* platinum-member.png */; }; + 2D939F232EC48FDE00F3464B /* PageNumberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939F222EC48FDE00F3464B /* PageNumberView.swift */; }; 2DAF1FE12E05D3ED006F6BC4 /* View+FontDesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */; }; 2DD8209C25DDD9BF0015A90D /* CopyImageActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */; }; 306F740B2D90AA01000717BC /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 306F740A2D90AA01000717BC /* KeychainAccess */; }; @@ -517,11 +521,14 @@ 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 = ""; }; + 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 = ""; }; 2D921268292F588100B16011 /* platinum-member.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "platinum-member.png"; sourceTree = ""; }; + 2D939F222EC48FDE00F3464B /* PageNumberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageNumberView.swift; 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 = ""; }; @@ -748,6 +755,8 @@ 1C29C382225853A300E1217A /* Posts */ = { isa = PBXGroup; children = ( + 2D62DEA72EBFEB1D00F7121B /* GradientView.swift */, + 2D62DEA52EBFE95500F7121B /* PostsPageTopBarLiquidGlass.swift */, 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */, 1C16FC191CD42EB300C88BD1 /* PostPreviewViewController.swift */, 1C16FBE61CBC671A00C88BD1 /* PostRenderModel.swift */, @@ -1119,6 +1128,8 @@ 1CF7FACB1BB60C2200A077F2 /* Views */ = { isa = PBXGroup; children = ( + 2D939F222EC48FDE00F3464B /* PageNumberView.swift */, + 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */, 2D10A3842E05C35700544F91 /* SearchView.swift */, 1CC22AB419F972C200D5BABD /* HairlineView.swift */, 1C16FC011CC29B2C00C88BD1 /* LoadingView.swift */, @@ -1541,7 +1552,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 */, @@ -1558,6 +1568,7 @@ 1CD005D11BB8800800232FFD /* ThreadsTableViewController.swift in Sources */, 1C25AC4F1F576BC600977D6F /* ContextObjectsDidChangeNotification.swift in Sources */, 2DD8209C25DDD9BF0015A90D /* CopyImageActivity.swift in Sources */, + 2D62DEA82EBFEB2000F7121B /* GradientView.swift in Sources */, 1CFC996A1BD3F402001180A7 /* PostsPageRefreshArrowView.swift in Sources */, 1CE2B76819C2372200FDC33E /* LoginViewController.swift in Sources */, 1C16FBD51CBA91ED00C88BD1 /* PostsViewExternalStylesheetLoader.swift in Sources */, @@ -1599,6 +1610,7 @@ 1C16FC181CD1848400C88BD1 /* ComposeTextViewController.swift in Sources */, 1CC256B11A38526B003FA7A8 /* MessageListViewController.swift in Sources */, 1CC065F72D67028F002BB6A0 /* AppIconImageNames.swift in Sources */, + 2D939F232EC48FDE00F3464B /* PageNumberView.swift in Sources */, 1C0D80001CF9FE70003EE2D1 /* Toolbar.swift in Sources */, 1C16FC101CC6F19700C88BD1 /* ThreadPreviewViewController.swift in Sources */, 1C09BFF01A09D485007C11F5 /* InAppActionCollectionViewLayout.swift in Sources */, @@ -1614,6 +1626,7 @@ 1C4EAD5E1BC0622D0008BE54 /* AwfulCore.swift in Sources */, 1C25AC521F57784A00977D6F /* AnnouncementListRefresher.swift in Sources */, 1C8A8CFA1A3C14DF00E4F6A4 /* ReplyWorkspace.swift in Sources */, + 2D62DEA42EBFE93800F7121B /* LiquidGlassTitleView.swift in Sources */, 1CC256BC1A3AA82F003FA7A8 /* ShowSmilieKeyboardCommand.swift in Sources */, 2DAF1FE12E05D3ED006F6BC4 /* View+FontDesign.swift in Sources */, 1C220E3B2B814D5A00DA92B0 /* SettingsViewController.swift in Sources */, @@ -1667,6 +1680,7 @@ 1C16FBC21CB9525B00C88BD1 /* NewThreadFieldView.swift in Sources */, 1C397C9B1BCC333D00CA7FD5 /* ResourceURLProtocol.swift in Sources */, 1C0060A52170347300E5329A /* HTMLReader.swift in Sources */, + 2D62DEA62EBFE95B00F7121B /* PostsPageTopBarLiquidGlass.swift in Sources */, 1CA887B01F40AE1A0059FEEC /* User+Presentation.swift in Sources */, 1C82AC4B199F585000CB15FE /* Selectotron.swift in Sources */, ); diff --git a/AwfulTheming/Sources/AwfulTheming/NigglyRefreshLottieView.swift b/AwfulTheming/Sources/AwfulTheming/NigglyRefreshLottieView.swift index db80600d6..b420bd3d6 100644 --- a/AwfulTheming/Sources/AwfulTheming/NigglyRefreshLottieView.swift +++ b/AwfulTheming/Sources/AwfulTheming/NigglyRefreshLottieView.swift @@ -40,6 +40,11 @@ final class NigglyRefreshLottieView: UIView, Themeable { animationView.loopMode = .loop animationView.animationSpeed = 1 animationView.translatesAutoresizingMaskIntoConstraints = false + + if #available(iOS 26.0, *) { + animationView.alpha = 0 + } + addSubview(animationView) directionalLayoutMargins = .init(top: 6, leading: 0, bottom: 6, trailing: 0) @@ -91,15 +96,50 @@ extension NigglyRefreshLottieView { case .initial: view.animationView.play() view.animationView.pause() - - case .releasing(let progress) where progress < 1: - view.animationView.pause() - - case .loading, .releasing: + + if #available(iOS 26.0, *) { + UIView.animate(withDuration: 0.2) { [weak view] in + view?.animationView.alpha = 0 + } + } + + case .releasing(let progress): + if progress > 0 { + if #available(iOS 26.0, *) { + UIView.animate(withDuration: 0.1) { [weak view] in + view?.animationView.alpha = 1 + } + } + + if progress < 1 { + view.animationView.pause() + } else { + view.animationView.play() + } + } else { + view.animationView.pause() + if #available(iOS 26.0, *) { + view.animationView.alpha = 0 + } + } + + case .loading: view.animationView.play() - + + if #available(iOS 26.0, *) { + UIView.animate(withDuration: 0.2) { [weak view] in + view?.animationView.alpha = 1 + } + } + case .finished: view.animationView.stop() + + if #available(iOS 26.0, *) { + UIView.animate(withDuration: 0.3, delay: 0.2, options: []) { [weak view] in + view?.animationView.alpha = 0 + } + } } } } diff --git a/AwfulTheming/Sources/AwfulTheming/Themes.plist b/AwfulTheming/Sources/AwfulTheming/Themes.plist index 23c4d65b3..8cee836ab 100644 --- a/AwfulTheming/Sources/AwfulTheming/Themes.plist +++ b/AwfulTheming/Sources/AwfulTheming/Themes.plist @@ -4,6 +4,8 @@ BYOB + menuAppearance + light keyboardAppearance Light relevantForumID @@ -59,6 +61,8 @@ default + menuAppearance + light statusBarBackground dark mode @@ -209,6 +213,8 @@ dark + menuAppearance + dark mode dark description @@ -319,6 +325,8 @@ alternateDefault + menuAppearance + light mode light description @@ -418,6 +426,8 @@ alternateDark + menuAppearance + dark mode dark description @@ -522,6 +532,8 @@ oledDark + menuAppearance + dark mode dark description @@ -634,6 +646,8 @@ brightLight + menuAppearance + light mode light description @@ -733,6 +747,8 @@ FYAD + menuAppearance + light mode light keyboardAppearance @@ -823,6 +839,8 @@ Gas Chamber + menuAppearance + light mode light relevantForumID @@ -844,6 +862,8 @@ Macinyos + menuAppearance + light mode light relevantForumID @@ -865,6 +885,8 @@ spankykongDark + menuAppearance + dark mode dark roundedFonts @@ -1060,6 +1082,8 @@ spankykongLight + menuAppearance + light statusBarBackground light mode @@ -1255,6 +1279,8 @@ Winpos 95 + menuAppearance + light toolbarTintColor #000000 relevantForumID @@ -1297,6 +1323,8 @@ YOSPOS + menuAppearance + dark mode dark keyboardAppearance @@ -1402,6 +1430,8 @@ YOSPOS (amber) + menuAppearance + dark mode dark keyboardAppearance diff --git a/AwfulTheming/Sources/AwfulTheming/ViewController.swift b/AwfulTheming/Sources/AwfulTheming/ViewController.swift index cb34f851a..f8fd5de4e 100644 --- a/AwfulTheming/Sources/AwfulTheming/ViewController.swift +++ b/AwfulTheming/Sources/AwfulTheming/ViewController.swift @@ -263,6 +263,41 @@ open class TableViewController: UITableViewController, Themeable { visible = false } + + // MARK: Scroll view delegate for iOS 26 dynamic color adaptation + + open override func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Update navigation bar tint for iOS 26+ dynamic colors + if #available(iOS 26.0, *) { + // Calculate scroll progress for smooth transition + let topInset = scrollView.adjustedContentInset.top + let currentOffset = scrollView.contentOffset.y + let topPosition = -topInset + + // Define transition zone (30 points for smooth fade) + let transitionDistance: CGFloat = 30.0 + + // Calculate progress (0.0 = fully at top, 1.0 = fully scrolled) + let progress: CGFloat + if currentOffset <= topPosition { + // At or above the top + progress = 0.0 + } else if currentOffset >= topPosition + transitionDistance { + // Fully scrolled past transition zone + progress = 1.0 + } else { + // In transition zone - calculate smooth progress + let distanceFromTop = currentOffset - topPosition + progress = distanceFromTop / transitionDistance + } + + // Find the navigation controller and call update method if it exists + if let navController = navigationController, + navController.responds(to: Selector(("updateNavigationBarTintForScrollProgress:"))) { + navController.perform(Selector(("updateNavigationBarTintForScrollProgress:")), with: NSNumber(value: Float(progress))) + } + } + } } /// A thin customization of UICollectionViewController that extends Theme support.