From af232fe3ba695c523fd6bf45da61a91ce2286c3d Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sun, 9 Nov 2025 08:48:02 +1100 Subject: [PATCH 1/8] Update Navbars and Toolbars for Liquid Glass - Alternative views for iOS26+. Original views still remain for previous versions. - PostsPageView toolbar page select count uses new vertical layout - Root tab navbars initially display opaque title headers, but switch to translucent on scroll --- App/Navigation/NavigationBar.swift | 18 +- App/Navigation/NavigationController.swift | 387 +++++++++++++++++- .../Messages/MessageViewController.swift | 163 +++++++- App/View Controllers/Posts/GradientView.swift | 58 +++ .../Posts/PostsPageRefreshSpinnerView.swift | 22 + .../Posts/PostsPageTopBar.swift | 2 +- .../Posts/PostsPageTopBarLiquidGlass.swift | 192 +++++++++ .../Posts/PostsPageView.swift | 101 ++++- .../Posts/PostsPageViewController.swift | 358 ++++++++++++++-- .../ProfileViewController.swift | 158 ++++++- App/Views/LiquidGlassTitleView.swift | 115 ++++++ App/Views/VerticalPageNumberView.swift | 144 +++++++ Awful.xcodeproj/project.pbxproj | 18 +- .../NigglyRefreshLottieView.swift | 52 ++- .../Sources/AwfulTheming/Themes.plist | 30 ++ .../Sources/AwfulTheming/ViewController.swift | 35 ++ 16 files changed, 1769 insertions(+), 84 deletions(-) create mode 100644 App/View Controllers/Posts/GradientView.swift create mode 100644 App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift create mode 100644 App/Views/LiquidGlassTitleView.swift create mode 100644 App/Views/VerticalPageNumberView.swift 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..77d6d950d 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -46,13 +46,40 @@ final class NavigationController: UINavigationController, Themeable { return navigationBar as! NavigationBar } + @available(iOS 26.0, *) + private func createGradientBackgroundImage(from color: UIColor, size: CGSize = CGSize(width: 1, height: 96)) -> UIImage? { + let format = UIGraphicsImageRendererFormat() + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: size, format: format) + + return renderer.image { context in + let colorSpace = CGColorSpaceCreateDeviceRGB() + let colors = [color.cgColor, color.withAlphaComponent(0.0).cgColor] as CFArray + let locations: [CGFloat] = [0.0, 1.0] + + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: locations) else { + return + } + + let startPoint = CGPoint(x: 0, y: 0) + let endPoint = CGPoint(x: 0, y: size.height) + + context.cgContext.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: []) + } + } + var theme: Theme { + // Get theme from the top view controller if it's Themeable + if let themeableVC = topViewController as? Themeable { + return themeableVC.theme + } + // Fallback to default theme return Theme.defaultTheme() } - // MARK: set the status icons (clock, wifi, battery) to black or white depending on the mode of the theme - // thanks sarunw https://sarunw.com/posts/how-to-set-status-bar-style/ + // MARK: Status bar style management var isDarkContentBackground = false + var isScrolledFromTop = false func statusBarEnterLightBackground() { isDarkContentBackground = false @@ -65,6 +92,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 +149,205 @@ final class NavigationController: UINavigationController, Themeable { } func themeDidChange() { + updateNavigationBarAppearance(with: theme) + } + + @objc func updateNavigationBarTintForScrollProgress(_ progress: NSNumber) { + guard #available(iOS 26.0, *) else { return } + + let progressValue = CGFloat(progress.floatValue) + + // First update the background appearance + updateNavigationBarBackgroundWithProgress(progressValue) + + // Then update text colors and tint based on threshold (keep existing behavior for text) + if progressValue < 0.01 { + // Fully at top: use theme colors + let textColor: UIColor = theme["navigationBarTextColor"]! + + // Set tintColor which affects back button and bar button items + awfulNavigationBar.tintColor = theme["navigationBarTextColor"] + + // Force update bar button items to use the theme color + if let topViewController = topViewController { + topViewController.navigationItem.leftBarButtonItem?.tintColor = textColor + topViewController.navigationItem.rightBarButtonItem?.tintColor = textColor + topViewController.navigationItem.leftBarButtonItems?.forEach { $0.tintColor = textColor } + topViewController.navigationItem.rightBarButtonItems?.forEach { $0.tintColor = textColor } + } + + isScrolledFromTop = false + + // Set status bar based on theme + if theme["statusBarBackground"] == "light" { + statusBarEnterLightBackground() + } else { + statusBarEnterDarkBackground() + } + } else if progressValue > 0.99 { + // Fully scrolled: nil for dynamic adaptation + awfulNavigationBar.tintColor = nil + + // Reset bar button items to inherit dynamic color + 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 } + // Convert boolean to progress (0 or 1) + let progress = isAtTop.boolValue ? 0.0 : 1.0 + updateNavigationBarTintForScrollProgress(NSNumber(value: progress)) + } + + private func updateNavigationBarBackgroundWithProgress(_ progress: CGFloat) { + guard #available(iOS 26.0, *) else { return } + + let appearance = UINavigationBarAppearance() + + if progress < 0.01 { + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme["navigationBarTintColor"] + } else if progress > 0.99 { + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.backgroundImage = nil + } else { + appearance.configureWithTransparentBackground() + + let opaqueColor: UIColor = theme["navigationBarTintColor"]! + let gradientBaseColor: UIColor = theme["listHeaderBackgroundColor"]! + + if let gradientImage = createGradientBackgroundImage(from: gradientBaseColor) { + appearance.backgroundImage = gradientImage + let overlayAlpha = 1.0 - progress + appearance.backgroundColor = opaqueColor.withAlphaComponent(overlayAlpha) + } else { + appearance.backgroundColor = interpolateColor(from: opaqueColor, to: gradientBaseColor, progress: progress) + } + } + + appearance.shadowColor = nil + appearance.shadowImage = nil + + if progress > 0.99 { + if let backImage = UIImage(named: "back")?.withTintColor(.label, renderingMode: .alwaysOriginal) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + } else { + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + } + + if progress < 0.01 { + 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 + } else if progress > 0.99 { + appearance.titleTextAttributes = [ + 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] = [ + .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 + } else { + let textColor: UIColor = theme["navigationBarTextColor"]! + + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + 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 + } + + awfulNavigationBar.standardAppearance = appearance + awfulNavigationBar.scrollEdgeAppearance = appearance + awfulNavigationBar.compactAppearance = appearance + awfulNavigationBar.compactScrollEdgeAppearance = appearance + + if progress < 0.01 { + awfulNavigationBar.tintColor = theme["navigationBarTextColor"] + } else if progress > 0.99 { + awfulNavigationBar.tintColor = nil + } + } + + private func interpolateColor(from startColor: UIColor, to endColor: UIColor, progress: CGFloat) -> UIColor { + let progress = max(0, min(1, progress)) // Clamp to 0-1 + + var startRed: CGFloat = 0, startGreen: CGFloat = 0, startBlue: CGFloat = 0, startAlpha: CGFloat = 0 + var endRed: CGFloat = 0, endGreen: CGFloat = 0, endBlue: CGFloat = 0, endAlpha: CGFloat = 0 + + startColor.getRed(&startRed, green: &startGreen, blue: &startBlue, alpha: &startAlpha) + endColor.getRed(&endRed, green: &endGreen, blue: &endBlue, alpha: &endAlpha) + + let red = startRed + (endRed - startRed) * progress + let green = startGreen + (endGreen - startGreen) * progress + let blue = startBlue + (endBlue - startBlue) * progress + let alpha = startAlpha + (endAlpha - startAlpha) * progress + + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } + + private func updateNavigationBarAppearance(with theme: Theme, for viewController: UIViewController? = nil) { awfulNavigationBar.barTintColor = theme["navigationBarTintColor"] - awfulNavigationBar.bottomBorderColor = theme["topBarBottomBorderColor"] - awfulNavigationBar.layer.shadowOpacity = Float(theme[double: "navigationBarShadowOpacity"] ?? 1) - awfulNavigationBar.tintColor = theme["navigationBarTextColor"] + + // iOS 26: Hide bottom border for liquid glass effect, earlier versions show themed border + if #available(iOS 26.0, *) { + awfulNavigationBar.bottomBorderColor = .clear + } else { + awfulNavigationBar.bottomBorderColor = theme["topBarBottomBorderColor"] + } + + // iOS 26: Remove shadow for liquid glass effect, earlier versions use themed shadow + if #available(iOS 26.0, *) { + awfulNavigationBar.layer.shadowOpacity = 0 + awfulNavigationBar.layer.shadowColor = UIColor.clear.cgColor + } else { + awfulNavigationBar.layer.shadowOpacity = Float(theme[double: "navigationBarShadowOpacity"] ?? 1) + } + // Apply theme's status bar setting if theme["statusBarBackground"] == "light" { statusBarEnterLightBackground() } else { @@ -128,19 +355,97 @@ final class NavigationController: UINavigationController, Themeable { } if #available(iOS 15.0, *) { - // Fix odd grey navigation bar background when scrolled to top on iOS 15. - let appearance = UINavigationBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = theme["navigationBarTintColor"] - appearance.shadowColor = nil - appearance.shadowImage = nil - - let textColor: UIColor? = theme["navigationBarTextColor"] - appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: textColor!, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold)] - - navigationBar.standardAppearance = appearance; - navigationBar.scrollEdgeAppearance = navigationBar.standardAppearance + if #available(iOS 26.0, *) { + let initialAppearance = UINavigationBarAppearance() + initialAppearance.configureWithOpaqueBackground() + initialAppearance.backgroundColor = theme["navigationBarTintColor"] + initialAppearance.shadowColor = nil + initialAppearance.shadowImage = nil + + let textColor: UIColor = theme["navigationBarTextColor"]! + + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + initialAppearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + + initialAppearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + let buttonAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColor, + .font: buttonFont + ] + initialAppearance.buttonAppearance.normal.titleTextAttributes = buttonAttributes + initialAppearance.buttonAppearance.highlighted.titleTextAttributes = buttonAttributes + initialAppearance.doneButtonAppearance.normal.titleTextAttributes = buttonAttributes + initialAppearance.doneButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + initialAppearance.backButtonAppearance.normal.titleTextAttributes = buttonAttributes + initialAppearance.backButtonAppearance.highlighted.titleTextAttributes = buttonAttributes + + // Apply the initial appearance to all states + awfulNavigationBar.standardAppearance = initialAppearance + awfulNavigationBar.scrollEdgeAppearance = initialAppearance + awfulNavigationBar.compactAppearance = initialAppearance + awfulNavigationBar.compactScrollEdgeAppearance = initialAppearance + + awfulNavigationBar.tintColor = textColor + + awfulNavigationBar.setNeedsLayout() + awfulNavigationBar.layoutIfNeeded() + + } else { + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme["navigationBarTintColor"] + appearance.shadowColor = nil + appearance.shadowImage = nil + + if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + } + + let textColor: UIColor = theme["navigationBarTextColor"]! + appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold)] + + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + 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 + + awfulNavigationBar.standardAppearance = appearance + awfulNavigationBar.scrollEdgeAppearance = appearance + awfulNavigationBar.compactAppearance = appearance + awfulNavigationBar.compactScrollEdgeAppearance = appearance + + awfulNavigationBar.tintColor = textColor + + awfulNavigationBar.setNeedsLayout() + awfulNavigationBar.layoutIfNeeded() + } + } else { + let fallbackTextColor = theme[uicolor: "navigationBarTextColor"]! + let attrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: fallbackTextColor, + .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + ] + UIBarButtonItem.appearance().setTitleTextAttributes(attrs, for: .normal) + UIBarButtonItem.appearance().setTitleTextAttributes(attrs, for: .highlighted) + + if let backImage = UIImage(named: "back") { + let tintedBackImage = backImage.withRenderingMode(.alwaysTemplate) + navigationBar.backIndicatorImage = tintedBackImage + navigationBar.backIndicatorTransitionMaskImage = tintedBackImage + } } } @@ -207,6 +512,52 @@ extension NavigationController: UIGestureRecognizerDelegate { extension NavigationController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + // Check if we're transitioning FROM a view controller with transformed navigation bar + // This ensures navigation bar is restored when leaving immersion mode views + if (navigationController.transitionCoordinator?.viewController(forKey: .from)) != nil { + // Reset navigation bar transform when it's not identity (immersion mode active) + if !navigationController.navigationBar.transform.isIdentity { + navigationController.navigationBar.transform = .identity + } + } + + let vcTheme: Theme + if let themeableViewController = viewController as? Themeable { + vcTheme = themeableViewController.theme + updateNavigationBarAppearance(with: vcTheme, for: viewController) + } else { + vcTheme = theme + updateNavigationBarAppearance(with: vcTheme, for: viewController) + } + + if awfulNavigationBar.backIndicatorImage == nil { + awfulNavigationBar.backIndicatorImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) + awfulNavigationBar.backIndicatorTransitionMaskImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) + } + + if !isScrolledFromTop { + let textColor: UIColor = vcTheme["navigationBarTextColor"]! + + awfulNavigationBar.tintColor = textColor + + viewController.navigationItem.leftBarButtonItem?.tintColor = textColor + viewController.navigationItem.rightBarButtonItem?.tintColor = textColor + viewController.navigationItem.leftBarButtonItems?.forEach { $0.tintColor = textColor } + viewController.navigationItem.rightBarButtonItems?.forEach { $0.tintColor = textColor } + + if viewControllers.count > 1 { + let previousVC = viewControllers[viewControllers.count - 2] + previousVC.navigationItem.backBarButtonItem?.tintColor = textColor + } + } + + awfulNavigationBar.setNeedsLayout() + awfulNavigationBar.layoutIfNeeded() + + if #available(iOS 26.0, *) { + isScrolledFromTop = false + } + if let unpopHandler = unpopHandler , animated { unpopHandler.navigationControllerDidBeginAnimating() diff --git a/App/View Controllers/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..8f9aec20b --- /dev/null +++ b/App/View Controllers/Posts/GradientView.swift @@ -0,0 +1,58 @@ +// GradientView.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US + +import AwfulTheming +import UIKit + +/// A UIView subclass that uses CAGradientLayer as its backing layer. +final class GradientView: UIView { + + override class var layerClass: AnyClass { + CAGradientLayer.self + } + + var gradientLayer: CAGradientLayer { + layer as! CAGradientLayer + } + + override init(frame: CGRect) { + super.init(frame: frame) + configureGradient() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureGradient() + } + + private func configureGradient() { + let isDarkMode = Theme.defaultTheme()[string: "mode"] == "dark" + + if isDarkMode { + gradientLayer.colors = [ + UIColor.black.cgColor, + UIColor.black.withAlphaComponent(0.8).cgColor, + UIColor.black.withAlphaComponent(0.4).cgColor, + UIColor.clear.cgColor + ] + gradientLayer.locations = [0.0, 0.3, 0.7, 1.0] + } else { + gradientLayer.colors = [ + UIColor.white.withAlphaComponent(0.8).cgColor, + UIColor.white.withAlphaComponent(0.6).cgColor, + UIColor.white.withAlphaComponent(0.2).cgColor, + UIColor.white.withAlphaComponent(0.02).cgColor, + UIColor.clear.cgColor + ] + gradientLayer.locations = [0.0, 0.4, 0.7, 0.9, 1.0] + } + + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0) + } + + func themeDidChange() { + configureGradient() + } +} diff --git a/App/View Controllers/Posts/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/PostsPageTopBar.swift b/App/View Controllers/Posts/PostsPageTopBar.swift index 475a176ff..317912685 100644 --- a/App/View Controllers/Posts/PostsPageTopBar.swift +++ b/App/View Controllers/Posts/PostsPageTopBar.swift @@ -6,7 +6,7 @@ import AwfulSettings import AwfulTheming import UIKit -final class PostsPageTopBar: UIView { +final class PostsPageTopBar: UIView, PostsPageTopBarProtocol { private lazy var stackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [parentForumButton, previousPostsButton, scrollToEndButton]) diff --git a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift new file mode 100644 index 000000000..b29041a81 --- /dev/null +++ b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift @@ -0,0 +1,192 @@ +// PostsPageTopBarLiquidGlass.swift +// +// Copyright 2025 Awful Contributors + +import AwfulSettings +import AwfulTheming +import UIKit + + +@available(iOS 26.0, *) +final class PostsPageTopBarLiquidGlass: UIView, PostsPageTopBarProtocol { + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [parentForumButton, previousPostsButton, scrollToEndButton]) + stackView.distribution = .fillEqually + stackView.spacing = 12 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private lazy var parentForumButton: UIButton = { + let button = PostsPageTopBarLiquidGlass.createCapsuleButton() + button.accessibilityLabel = LocalizedString("posts-page.parent-forum-button.accessibility-label") + button.accessibilityHint = LocalizedString("posts-page.parent-forum-button.accessibility-hint") + button.setTitle(LocalizedString("posts-page.parent-forum-button.title"), for: .normal) + return button + }() + + private lazy var previousPostsButton: UIButton = { + let button = PostsPageTopBarLiquidGlass.createCapsuleButton() + button.accessibilityLabel = LocalizedString("posts-page.previous-posts-button.accessibility-label") + button.setTitle(LocalizedString("posts-page.previous-posts-button.title"), for: .normal) + return button + }() + + private let scrollToEndButton: UIButton = { + let button = PostsPageTopBarLiquidGlass.createCapsuleButton() + button.accessibilityLabel = LocalizedString("posts-page.scroll-to-end-button.accessibility-label") + button.setTitle(LocalizedString("posts-page.scroll-to-end-button.title"), for: .normal) + return button + }() + + @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics + + override init(frame: CGRect) { + super.init(frame: frame) + + parentForumButton.addTarget(self, action: #selector(didTapParentForum), for: .primaryActionTriggered) + previousPostsButton.addTarget(self, action: #selector(didTapPreviousPosts), for: .primaryActionTriggered) + scrollToEndButton.addTarget(self, action: #selector(didTapScrollToEnd), for: .primaryActionTriggered) + + addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + + parentForumButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), + previousPostsButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), + scrollToEndButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 32) + ]) + + updateButtonsEnabled() + } + + private static func createCapsuleButton() -> UIButton { + let button = GlassButton() + button.translatesAutoresizingMaskIntoConstraints = false + return button + } + + private class GlassButton: UIButton { + private let glassView: UIVisualEffectView + private let label: UILabel + + override init(frame: CGRect) { + let glassEffect = UIGlassEffect() + glassView = UIVisualEffectView(effect: glassEffect) + glassView.translatesAutoresizingMaskIntoConstraints = false + glassView.isUserInteractionEnabled = false + glassView.layer.cornerRadius = 16 + glassView.layer.masksToBounds = true + glassView.layer.cornerCurve = .continuous + glassView.layer.shadowOpacity = 0 + + label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFontForTextStyle(.footnote, sizeAdjustment: 0, weight: .medium) + label.numberOfLines = 1 + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.85 + label.lineBreakMode = .byTruncatingTail + label.textAlignment = .center + label.isUserInteractionEnabled = false + + super.init(frame: frame) + + backgroundColor = .clear + + insertSubview(glassView, at: 0) + glassView.contentView.addSubview(label) + + NSLayoutConstraint.activate([ + glassView.leadingAnchor.constraint(equalTo: leadingAnchor), + glassView.trailingAnchor.constraint(equalTo: trailingAnchor), + glassView.topAnchor.constraint(equalTo: topAnchor), + glassView.bottomAnchor.constraint(equalTo: bottomAnchor), + + label.leadingAnchor.constraint(equalTo: glassView.contentView.leadingAnchor, constant: 12), + label.trailingAnchor.constraint(equalTo: glassView.contentView.trailingAnchor, constant: -12), + label.topAnchor.constraint(equalTo: glassView.contentView.topAnchor, constant: 6), + label.bottomAnchor.constraint(equalTo: glassView.contentView.bottomAnchor, constant: -6), + + heightAnchor.constraint(greaterThanOrEqualToConstant: 32) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setTitle(_ title: String?, for state: UIControl.State) { + if state == .normal { + label.text = title + } + } + + override var isEnabled: Bool { + didSet { + label.alpha = isEnabled ? 1.0 : 0.5 + } + } + } + + func themeDidChange(_ theme: Theme) { + for button in [parentForumButton, previousPostsButton, scrollToEndButton] { + button.setNeedsUpdateConfiguration() + } + } + + private func updateButtonsEnabled() { + parentForumButton.isEnabled = goToParentForum != nil + previousPostsButton.isEnabled = showPreviousPosts != nil + scrollToEndButton.isEnabled = scrollToEnd != nil + } + + @objc private func didTapParentForum(_ sender: UIButton) { + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + goToParentForum?() + } + var goToParentForum: (() -> Void)? { + didSet { updateButtonsEnabled() } + } + + @objc private func didTapPreviousPosts(_ sender: UIButton) { + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + showPreviousPosts?() + } + var showPreviousPosts: (() -> Void)? { + didSet { updateButtonsEnabled() } + } + + @objc private func didTapScrollToEnd(_ sender: UIButton) { + if enableHaptics { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + scrollToEnd?() + } + var scrollToEnd: (() -> Void)? { + didSet { updateButtonsEnabled() } + } + + // MARK: Gunk + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +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..ca07f88d6 100644 --- a/App/View Controllers/Posts/PostsPageView.swift +++ b/App/View Controllers/Posts/PostsPageView.swift @@ -174,7 +174,7 @@ final class PostsPageView: UIView { // MARK: Top bar - var topBar: PostsPageTopBar { + var topBar: PostsPageTopBarProtocol { return topBarContainer.topBar } @@ -216,6 +216,20 @@ 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 safeAreaGradientView: GradientView = { + let view = GradientView() + view.isUserInteractionEnabled = false + // Show gradient only on iOS 26+ + 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 +247,7 @@ final class PostsPageView: UIView { toolbar.overrideUserInterfaceStyle = Theme.defaultTheme()["mode"] == "light" ? .light : .dark addSubview(renderView) + addSubview(safeAreaGradientView) // Add gradient before top bar so it appears behind addSubview(topBarContainer) addSubview(loadingViewContainer) addSubview(toolbar) @@ -250,6 +265,18 @@ final class PostsPageView: UIView { renderView.frame = bounds loadingViewContainer.frame = bounds + // Position gradient view in the status bar area only + if #available(iOS 26.0, *) { + // Position gradient to cover only the top safe area (status bar/notch) + // Use actual device safe area top instead of layoutMargins to prevent extending into content + let gradientHeight: CGFloat = window?.safeAreaInsets.top ?? safeAreaInsets.top + safeAreaGradientView.frame = CGRect( + x: bounds.minX, + y: bounds.minY, // Start at top of screen + width: bounds.width, + height: gradientHeight) + } + let toolbarHeight = toolbar.sizeThatFits(bounds.size).height toolbar.frame = CGRect( x: bounds.minX, @@ -322,11 +349,22 @@ 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 + // Only set toolbar colors for iOS < 26 + if #available(iOS 26.0, *) { + // Let iOS 26+ handle toolbar colors and borders automatically + 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()) + + // Update gradient view for theme changes + if #available(iOS 26.0, *) { + safeAreaGradientView.themeDidChange() + } } // MARK: Gunk @@ -343,8 +381,13 @@ 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() + fileprivate lazy var topBar: PostsPageTopBarProtocol = { + let topBar: PostsPageTopBarProtocol + if #available(iOS 26.0, *) { + topBar = PostsPageTopBarLiquidGlass() + } else { + topBar = PostsPageTopBar() + } topBar.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true return topBar }() @@ -355,6 +398,11 @@ extension PostsPageView { clipsToBounds = true + // Use clear background for iOS 26+ to allow liquid glass effect + if #available(iOS 26.0, *) { + backgroundColor = .clear + } + addSubview(topBar, constrainEdges: [.bottom, .left, .right]) } @@ -642,5 +690,46 @@ extension PostsPageView: ScrollViewDelegateExtras { break } } + + // Update navigation bar appearance for iOS 26+ based on scroll progress + if #available(iOS 26.0, *) { + // Calculate scroll progress for smooth navbar 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 parent view controller by walking up the responder chain + var responder: UIResponder? = self + while responder != nil { + if let viewController = responder as? PostsPageViewController, + let navController = viewController.navigationController as? NavigationController { + // NavigationController will handle the navbar tint color and back button + navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) + + // Update the liquid glass title view text color based on scroll progress + viewController.updateTitleViewTextColorForScrollProgress(progress) + + break + } + responder = responder?.next + } + } } } diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 31e12e867..6d6d782da 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -56,6 +56,29 @@ 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 _liquidGlassTitleView == nil { + _liquidGlassTitleView = LiquidGlassTitleView() + } + return _liquidGlassTitleView as? LiquidGlassTitleView + } + + /// Updates the title view text color based on scroll position for dynamic adaptation + @available(iOS 26.0, *) + func updateTitleViewTextColorForScrollProgress(_ progress: CGFloat) { + if progress < 0.01 { + // At top: use theme color + liquidGlassTitleView?.textColor = theme["navigationBarTextColor"] + } else if progress > 0.99 { + // Fully scrolled: use nil for dynamic color adaptation + liquidGlassTitleView?.textColor = nil + } + } + func threadActionsMenu() -> UIMenu { return UIMenu(title: thread.title ?? "", image: nil, identifier: nil, options: .displayInline, children: [ // Bookmark @@ -126,6 +149,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 +162,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 +221,27 @@ 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["navigationBarTextColor"] + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) + default: + glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) + } + + navigationItem.titleView = glassView + + configureNavigationBarForLiquidGlass() + } else { + navigationItem.titleView = nil + navigationItem.titleLabel.text = title + } + } } /** @@ -284,7 +342,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.verticalPageNumberView.currentPage = 0 + self.verticalPageNumberView.totalPages = self.numberOfPages > 0 ? self.numberOfPages : 0 + } else { + self.currentPageItem.title = "Page ? of \(pageCount)" + } case .last, .nextUnread, .specific: break @@ -334,7 +397,14 @@ 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, *) { + // Use vertical view: show unknown current page with known total + self.verticalPageNumberView.currentPage = 0 // Will display as "?" + self.verticalPageNumberView.totalPages = self.numberOfPages > 0 ? self.numberOfPages : 0 + // iOS 26+ handles colors automatically + } else { + self.currentPageItem.title = "Page ? of \(pageCount)" + } case .last, .nextUnread, .specific: break @@ -423,6 +493,12 @@ 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, *) { + // Let iOS 26+ handle the color automatically + } else { + item.tintColor = theme["navigationBarTextColor"] + } return item }() @@ -514,6 +590,12 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Settings" + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + // Let iOS 26+ handle the color automatically + } else { + item.tintColor = theme["toolbarTextColor"] + } return item }() @@ -529,9 +611,23 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Previous page" + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + // Let iOS 26+ handle the color automatically + } else { + item.tintColor = theme["toolbarTextColor"] + } return item }() + private lazy var verticalPageNumberView: VerticalPageNumberView = { + let view = VerticalPageNumberView() + 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 +638,26 @@ final class PostsPageViewController: ViewController { popover.barButtonItem = action.sender as? UIBarButtonItem } }) - item.possibleTitles = ["2345 / 2345"] + + // Set up the bar button item based on iOS version + if #available(iOS 26.0, *) { + // Use vertical page number view for modern appearance wrapped in container for centering + let containerView = UIView() + containerView.addSubview(verticalPageNumberView) + verticalPageNumberView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + verticalPageNumberView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + verticalPageNumberView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + containerView.widthAnchor.constraint(equalTo: verticalPageNumberView.widthAnchor, constant: 6), // 3 points padding on each side + // Add a bit of vertical padding so the content appears visually centered in the toolbar + containerView.heightAnchor.constraint(equalTo: verticalPageNumberView.heightAnchor, constant: 5) + ]) + item.customView = containerView + } else { + // Use traditional text title for iOS 18 and below + item.possibleTitles = ["2345 / 2345"] + } + item.accessibilityHint = "Opens page picker" return item }() @@ -559,16 +674,45 @@ final class PostsPageViewController: ViewController { } )) item.accessibilityLabel = "Next page" + // Only set explicit tint color for iOS < 26 + if #available(iOS 26.0, *) { + // Let iOS 26+ handle the color automatically + } 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, *) { + // Let iOS 26+ handle the color automatically + } else { + item.tintColor = theme["toolbarTextColor"] } - return buttonItem + return item } private func refetchPosts() { @@ -640,11 +784,27 @@ 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) + // Update page display based on iOS version + if #available(iOS 26.0, *) { + // Use vertical page number view for modern appearance + verticalPageNumberView.currentPage = pageNumber + verticalPageNumberView.totalPages = numberOfPages + // iOS 26+ handles colors automatically + currentPageItem.accessibilityLabel = "Page \(pageNumber) of \(numberOfPages)" + } else { + // Use traditional text title for iOS 18 and below + 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, *) { + verticalPageNumberView.currentPage = 0 + verticalPageNumberView.totalPages = 0 + } else { + currentPageItem.title = "" + } currentPageItem.accessibilityLabel = nil } @@ -658,6 +818,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() { @@ -700,6 +871,18 @@ final class PostsPageViewController: ViewController { popover.barButtonItem = sender } } + + 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 = verticalPageNumberView + popover.sourceRect = verticalPageNumberView.bounds + } + } @objc private func loadPreviousPage(_ sender: UIKeyCommand) { if enableHaptics { @@ -1040,10 +1223,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 +1660,49 @@ 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["navigationBarTextColor"] + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) + default: + glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) + } - switch UIDevice.current.userInterfaceIdiom { - case .pad: - navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) - navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! - default: - navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) - navigationItem.titleLabel.numberOfLines = 2 - navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! + // Update navigation bar configuration based on new theme + configureNavigationBarForLiquidGlass() + } else { + // Apply theme to regular title label for iOS < 26 + navigationItem.titleLabel.textColor = theme["navigationBarTextColor"] + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) + navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! + default: + navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) + navigationItem.titleLabel.numberOfLines = 2 + navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! + } + } + + // Update navigation bar button colors (only for iOS < 26) + if #available(iOS 26.0, *) { + // Let iOS 26+ handle colors automatically + } 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 +1722,21 @@ final class PostsPageViewController: ViewController { postsView.toolbar.standardAppearance = appearance postsView.toolbar.compactAppearance = appearance - - if #available(iOS 15.0, *) { - postsView.toolbar.scrollEdgeAppearance = appearance - postsView.toolbar.compactScrollEdgeAppearance = appearance + postsView.toolbar.scrollEdgeAppearance = appearance + postsView.toolbar.compactScrollEdgeAppearance = appearance + + // Update toolbar button text colors (only for iOS < 26) + if #available(iOS 26.0, *) { + // Let iOS 26+ handle colors automatically + } else { + backItem.tintColor = theme["toolbarTextColor"] + forwardItem.tintColor = theme["toolbarTextColor"] + settingsItem.tintColor = theme["toolbarTextColor"] + verticalPageNumberView.textColor = theme["toolbarTextColor"] ?? UIColor.systemBlue } - - postsView.toolbar.overrideUserInterfaceStyle = theme["mode"] == "light" ? .light : .dark + + // Update toolbar items to refresh the actions button + updateToolbarItems() messageViewController?.themeDidChange() } @@ -1536,12 +1757,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) @@ -1632,6 +1847,75 @@ final class PostsPageViewController: ViewController { // See commentary in `viewDidLoad()` about our layout strategy here. tl;dr layout margins were the highest-level approach available on all versions of iOS that Awful supported, so we'll use them exclusively to represent the safe area. Probably not necessary anymore. postsView.layoutMargins = view.safeAreaInsets } + + @available(iOS 26.0, *) + private func configureNavigationBarForLiquidGlass() { + guard let navigationBar = navigationController?.navigationBar else { return } + guard let navController = navigationController as? NavigationController else { return } + + // Hide the custom bottom border from NavigationBar for liquid glass effect + if let awfulNavigationBar = navigationBar as? NavigationBar { + awfulNavigationBar.bottomBorderColor = .clear + } + + // 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 + + // Set initial text colors from theme + 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 + + // 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 + + // CRITICAL: Set tintColor AFTER applying appearance to ensure back button uses theme color + let navTextColor: UIColor = theme["navigationBarTextColor"]! + print("DEBUG: Setting navigationBar.tintColor to: \(navTextColor) for theme: \(theme["name"] ?? "unknown")") + 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)) + + // Force navigation bar to update its appearance + navigationBar.setNeedsLayout() + navigationBar.layoutIfNeeded() + + // Try setting the back button tint directly on the previous view controller + if let previousVC = navigationController?.viewControllers.dropLast().last { + previousVC.navigationItem.backBarButtonItem?.tintColor = navTextColor + } + + // The NavigationController will handle the dynamic transition based on scroll position + // iOS 26 handles status bar style automatically with liquid glass + } 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..f29d7776d --- /dev/null +++ b/App/Views/LiquidGlassTitleView.swift @@ -0,0 +1,115 @@ +// LiquidGlassTitleView.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US + +import UIKit + +@available(iOS 26.0, *) +final class LiquidGlassTitleView: UIView { + + private var visualEffectView: UIVisualEffectView = { + let effect = UIGlassEffect() + let view = UIVisualEffectView(effect: effect) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.numberOfLines = 2 + label.adjustsFontForContentSizeCategory = true + label.lineBreakMode = .byWordWrapping + return label + }() + + var title: String? { + get { titleLabel.text } + set { + titleLabel.text = newValue + updateTitleDisplay() + } + } + + var textColor: UIColor? { + get { titleLabel.textColor } + set { titleLabel.textColor = newValue } + } + + var font: UIFont? { + get { titleLabel.font } + set { + titleLabel.font = newValue + updateTitleDisplay() + } + } + + func setUseDarkGlass(_ useDark: Bool) { + visualEffectView.overrideUserInterfaceStyle = useDark ? .dark : .unspecified + } + + private func updateTitleDisplay() { + guard let text = titleLabel.text, !text.isEmpty else { return } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + paragraphStyle.lineSpacing = -2 + paragraphStyle.lineBreakMode = .byWordWrapping + + let attributes: [NSAttributedString.Key: Any] = [ + .paragraphStyle: paragraphStyle, + .font: titleLabel.font ?? UIFont.preferredFont(forTextStyle: .callout) + ] + + titleLabel.attributedText = NSAttributedString(string: text, attributes: attributes) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + } + + private func setupViews() { + addSubview(visualEffectView) + visualEffectView.contentView.addSubview(titleLabel) + + NSLayoutConstraint.activate([ + visualEffectView.topAnchor.constraint(equalTo: topAnchor), + visualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + visualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + visualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + + titleLabel.topAnchor.constraint(equalTo: visualEffectView.contentView.topAnchor, constant: 8), + titleLabel.leadingAnchor.constraint(equalTo: visualEffectView.contentView.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: visualEffectView.contentView.trailingAnchor, constant: -16), + titleLabel.bottomAnchor.constraint(equalTo: visualEffectView.contentView.bottomAnchor, constant: -8) + ]) + + isAccessibilityElement = false + titleLabel.isAccessibilityElement = true + titleLabel.accessibilityTraits = .header + } + + override func layoutSubviews() { + super.layoutSubviews() + + let cornerRadius = bounds.height / 2 + visualEffectView.layer.cornerRadius = cornerRadius + visualEffectView.layer.masksToBounds = true + visualEffectView.layer.cornerCurve = .continuous + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: 320, height: 56) + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + return CGSize(width: 320, height: 56) + } +} diff --git a/App/Views/VerticalPageNumberView.swift b/App/Views/VerticalPageNumberView.swift new file mode 100644 index 000000000..b03cb8aae --- /dev/null +++ b/App/Views/VerticalPageNumberView.swift @@ -0,0 +1,144 @@ +// VerticalPageNumberView.swift +// +// Copyright © 2025 Awful Contributors. All rights reserved. +// + +import UIKit + +final class VerticalPageNumberView: UIView { + private let currentPageLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.font = UIFont.preferredFontForTextStyle(.footnote, fontName: nil, sizeAdjustment: 0, weight: .medium) + label.adjustsFontForContentSizeCategory = true + return label + }() + + private let totalPagesLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.font = UIFont.preferredFontForTextStyle(.footnote, fontName: nil, sizeAdjustment: 0, weight: .medium) + label.adjustsFontForContentSizeCategory = true + return label + }() + + private let separatorLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = " /" + label.textAlignment = .center + label.font = UIFont.preferredFontForTextStyle(.footnote, fontName: nil, sizeAdjustment: 0, weight: .medium) + label.adjustsFontForContentSizeCategory = true + return label + }() + private let interRowSpacing: CGFloat = -2 + + var currentPage: Int = 1 { + didSet { + updateDisplay() + } + } + + var totalPages: Int = 1 { + didSet { + updateDisplay() + } + } + + var textColor: UIColor = { + return .label + }() { + didSet { + updateColors() + } + } + var onTap: (() -> Void)? + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + updateDisplay() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + updateDisplay() + } + private func setupViews() { + addSubview(currentPageLabel) + addSubview(totalPagesLabel) + addSubview(separatorLabel) + isUserInteractionEnabled = true + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tapGesture) + NSLayoutConstraint.activate([ + currentPageLabel.topAnchor.constraint(equalTo: topAnchor), + currentPageLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + totalPagesLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + totalPagesLabel.centerXAnchor.constraint(equalTo: currentPageLabel.centerXAnchor), + currentPageLabel.widthAnchor.constraint(equalTo: totalPagesLabel.widthAnchor), + separatorLabel.centerYAnchor.constraint(equalTo: currentPageLabel.centerYAnchor), + separatorLabel.leadingAnchor.constraint(equalTo: currentPageLabel.trailingAnchor), + totalPagesLabel.topAnchor.constraint(equalTo: currentPageLabel.bottomAnchor, constant: interRowSpacing), + widthAnchor.constraint(greaterThanOrEqualToConstant: 40), + leadingAnchor.constraint(lessThanOrEqualTo: currentPageLabel.leadingAnchor), + trailingAnchor.constraint(greaterThanOrEqualTo: separatorLabel.trailingAnchor) + ]) + currentPageLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + totalPagesLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + separatorLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + updateColors() + } + @objc private func handleTap() { + onTap?() + } + private func updateDisplay() { + let formatter = NumberFormatter() + formatter.numberStyle = .none + formatter.usesGroupingSeparator = false + if currentPage == 0 { + currentPageLabel.text = "?" + } else { + currentPageLabel.text = formatter.string(from: NSNumber(value: currentPage)) ?? "\(currentPage)" + } + if totalPages == 0 { + totalPagesLabel.text = "?" + } else { + totalPagesLabel.text = formatter.string(from: NSNumber(value: totalPages)) ?? "\(totalPages)" + } + let currentPageText = currentPage == 0 ? "?" : "\(currentPage)" + let totalPagesText = totalPages == 0 ? "?" : "\(totalPages)" + accessibilityLabel = "Page \(currentPageText) of \(totalPagesText)" + accessibilityHint = "Opens page picker" + } + + private func updateColors() { + currentPageLabel.textColor = textColor + totalPagesLabel.textColor = textColor + separatorLabel.textColor = textColor + } + override var intrinsicContentSize: CGSize { + let currentSize = currentPageLabel.intrinsicContentSize + let totalSize = totalPagesLabel.intrinsicContentSize + let separatorSize = separatorLabel.intrinsicContentSize + let width = max(currentSize.width, totalSize.width) + separatorSize.width + let height = currentSize.height + totalSize.height + interRowSpacing + + return CGSize(width: width, height: height) + } + + override func layoutSubviews() { + super.layoutSubviews() + invalidateIntrinsicContentSize() + } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { + invalidateIntrinsicContentSize() + } + } +} diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index b32003490..4fadc6a72 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -202,6 +202,10 @@ 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 */; }; + 2D62DEAA2EBFED4B00F7121B /* VerticalPageNumberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA92EBFED4800F7121B /* VerticalPageNumberView.swift */; }; 2D921269292F588100B16011 /* platinum-member.png in Resources */ = {isa = PBXBuildFile; fileRef = 2D921268292F588100B16011 /* platinum-member.png */; }; 2DAF1FE12E05D3ED006F6BC4 /* View+FontDesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */; }; 2DD8209C25DDD9BF0015A90D /* CopyImageActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */; }; @@ -517,11 +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 = ""; }; + 2D62DEA92EBFED4800F7121B /* VerticalPageNumberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalPageNumberView.swift; sourceTree = ""; }; 2D921268292F588100B16011 /* platinum-member.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "platinum-member.png"; sourceTree = ""; }; 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+FontDesign.swift"; sourceTree = ""; }; 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CopyImageActivity.swift; sourceTree = ""; }; 30E0C5162E35C89D0030DC0A /* AnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; - 30E0C5172E35C89D0030DC0A /* SmilieData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieData.swift; sourceTree = ""; }; 30E0C5182E35C89D0030DC0A /* SmilieGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieGridItem.swift; sourceTree = ""; }; 30E0C5192E35C89D0030DC0A /* SmiliePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmiliePickerView.swift; sourceTree = ""; }; 30E0C51A2E35C89D0030DC0A /* SmilieSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieSearchViewModel.swift; sourceTree = ""; }; @@ -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 = ( + 2D62DEA92EBFED4800F7121B /* VerticalPageNumberView.swift */, + 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */, 2D10A3842E05C35700544F91 /* SearchView.swift */, 1CC22AB419F972C200D5BABD /* HairlineView.swift */, 1C16FC011CC29B2C00C88BD1 /* LoadingView.swift */, @@ -1536,12 +1547,12 @@ buildActionMask = 2147483647; files = ( 1CD005CF1BB734E900232FFD /* BookmarksTableViewController.swift in Sources */, + 2D62DEAA2EBFED4B00F7121B /* VerticalPageNumberView.swift in Sources */, 1C29BD55225121F100E1217A /* RootTabBarController.swift in Sources */, 1C40796A1A228DA6004A082F /* CopyURLActivity.swift in Sources */, 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 +1569,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 */, @@ -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. From ef6e3342bc24f13ce866aefa2ba14107480dcc30 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sun, 9 Nov 2025 09:30:59 +1100 Subject: [PATCH 2/8] Fix nav title textColor after scroll using the theme's "mode" value. Specifically fixes YOSPOS title text so it is visible after scrolling the threadlist --- App/Navigation/NavigationController.swift | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index 77d6d950d..d45aed24b 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -266,12 +266,17 @@ final class NavigationController: UINavigationController, Themeable { appearance.doneButtonAppearance.highlighted.titleTextAttributes = buttonAttributes appearance.backButtonAppearance.normal.titleTextAttributes = buttonAttributes appearance.backButtonAppearance.highlighted.titleTextAttributes = buttonAttributes - } else if progress > 0.99 { + } + else if progress > 0.99 { + let textColor: UIColor = theme["mode"] == "dark" ? .white : .black + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textColor, NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) ] let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) let buttonAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColor, .font: buttonFont ] appearance.buttonAppearance.normal.titleTextAttributes = buttonAttributes @@ -512,14 +517,6 @@ extension NavigationController: UIGestureRecognizerDelegate { extension NavigationController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { - // Check if we're transitioning FROM a view controller with transformed navigation bar - // This ensures navigation bar is restored when leaving immersion mode views - if (navigationController.transitionCoordinator?.viewController(forKey: .from)) != nil { - // Reset navigation bar transform when it's not identity (immersion mode active) - if !navigationController.navigationBar.transform.isIdentity { - navigationController.navigationBar.transform = .identity - } - } let vcTheme: Theme if let themeableViewController = viewController as? Themeable { From 94c0b986f3bf315a4f5988366118027fe292c06e Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Tue, 11 Nov 2025 20:17:12 +1100 Subject: [PATCH 3/8] Fixing top nav buttons in iOS26 - Preview and Post buttons had a blue background, which conflicted with multiple blue navbar header backgrounds. Changed these to .plain style. - Removed all themed tinting for text and icon top buttons in iOS26. Now these will be either black or white depending on the mode. Themed tints were making these buttons illegible in liquid glass --- .../CompositionViewController.swift | 9 +- App/Navigation/NavigationController.swift | 150 +++++++----------- App/Posts/ReplyWorkspace.swift | 20 ++- .../Posts/PostsPageViewController.swift | 10 +- 4 files changed, 88 insertions(+), 101 deletions(-) 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/NavigationController.swift b/App/Navigation/NavigationController.swift index d45aed24b..d20a45001 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -14,6 +14,13 @@ import UIKit - On iPhone, allows swiping from the *right* screen edge to unpop a view controller. */ final class NavigationController: UINavigationController, Themeable { + + /// Scroll progress thresholds for navigation bar appearance transitions + private enum ScrollProgress { + static let atTop: CGFloat = 0.01 + static let fullyScrolled: CGFloat = 0.99 + } + fileprivate weak var realDelegate: UINavigationControllerDelegate? fileprivate lazy var unpopHandler: UnpoppingViewHandler? = { guard UIDevice.current.userInterfaceIdiom == .phone else { return nil } @@ -152,43 +159,36 @@ final class NavigationController: UINavigationController, Themeable { 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) - // First update the background appearance updateNavigationBarBackgroundWithProgress(progressValue) - // Then update text colors and tint based on threshold (keep existing behavior for text) - if progressValue < 0.01 { - // Fully at top: use theme colors - let textColor: UIColor = theme["navigationBarTextColor"]! - - // Set tintColor which affects back button and bar button items - awfulNavigationBar.tintColor = theme["navigationBarTextColor"] - - // Force update bar button items to use the theme color - if let topViewController = topViewController { - topViewController.navigationItem.leftBarButtonItem?.tintColor = textColor - topViewController.navigationItem.rightBarButtonItem?.tintColor = textColor - topViewController.navigationItem.leftBarButtonItems?.forEach { $0.tintColor = textColor } - topViewController.navigationItem.rightBarButtonItems?.forEach { $0.tintColor = textColor } - } - + if progressValue < ScrollProgress.atTop { isScrolledFromTop = false - // Set status bar based on theme if theme["statusBarBackground"] == "light" { statusBarEnterLightBackground() } else { statusBarEnterDarkBackground() } - } else if progressValue > 0.99 { - // Fully scrolled: nil for dynamic adaptation + } else if progressValue > ScrollProgress.fullyScrolled { awfulNavigationBar.tintColor = nil - // Reset bar button items to inherit dynamic color if let topViewController = topViewController { topViewController.navigationItem.leftBarButtonItem?.tintColor = nil topViewController.navigationItem.rightBarButtonItem?.tintColor = nil @@ -203,20 +203,29 @@ final class NavigationController: UINavigationController, Themeable { @objc func updateNavigationBarTintForScrollPosition(_ isAtTop: NSNumber) { guard #available(iOS 26.0, *) else { return } - // Convert boolean to progress (0 or 1) 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) { - guard #available(iOS 26.0, *) else { return } - let appearance = UINavigationBarAppearance() - if progress < 0.01 { + if progress < ScrollProgress.atTop { appearance.configureWithOpaqueBackground() appearance.backgroundColor = theme["navigationBarTintColor"] - } else if progress > 0.99 { + } else if progress > ScrollProgress.fullyScrolled { appearance.configureWithTransparentBackground() appearance.backgroundColor = .clear appearance.backgroundImage = nil @@ -238,7 +247,7 @@ final class NavigationController: UINavigationController, Themeable { appearance.shadowColor = nil appearance.shadowImage = nil - if progress > 0.99 { + if progress > ScrollProgress.fullyScrolled { if let backImage = UIImage(named: "back")?.withTintColor(.label, renderingMode: .alwaysOriginal) { appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } @@ -248,7 +257,7 @@ final class NavigationController: UINavigationController, Themeable { } } - if progress < 0.01 { + if progress < ScrollProgress.atTop { let textColor: UIColor = theme["navigationBarTextColor"]! appearance.titleTextAttributes = [ @@ -256,18 +265,9 @@ final class NavigationController: UINavigationController, Themeable { 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 + configureButtonAppearance(appearance, font: buttonFont) } - else if progress > 0.99 { + else if progress > ScrollProgress.fullyScrolled { let textColor: UIColor = theme["mode"] == "dark" ? .white : .black appearance.titleTextAttributes = [ @@ -275,16 +275,7 @@ final class NavigationController: UINavigationController, Themeable { 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 + configureButtonAppearance(appearance, font: buttonFont) } else { let textColor: UIColor = theme["navigationBarTextColor"]! @@ -293,16 +284,7 @@ final class NavigationController: UINavigationController, Themeable { 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 + configureButtonAppearance(appearance, font: buttonFont) } awfulNavigationBar.standardAppearance = appearance @@ -310,9 +292,9 @@ final class NavigationController: UINavigationController, Themeable { awfulNavigationBar.compactAppearance = appearance awfulNavigationBar.compactScrollEdgeAppearance = appearance - if progress < 0.01 { - awfulNavigationBar.tintColor = theme["navigationBarTextColor"] - } else if progress > 0.99 { + if progress < ScrollProgress.atTop { + awfulNavigationBar.tintColor = theme["mode"] == "dark" ? .white : .black + } else if progress > ScrollProgress.fullyScrolled { awfulNavigationBar.tintColor = nil } } @@ -378,24 +360,14 @@ final class NavigationController: UINavigationController, Themeable { 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 - ] - initialAppearance.buttonAppearance.normal.titleTextAttributes = buttonAttributes - initialAppearance.buttonAppearance.highlighted.titleTextAttributes = buttonAttributes - initialAppearance.doneButtonAppearance.normal.titleTextAttributes = buttonAttributes - initialAppearance.doneButtonAppearance.highlighted.titleTextAttributes = buttonAttributes - initialAppearance.backButtonAppearance.normal.titleTextAttributes = buttonAttributes - initialAppearance.backButtonAppearance.highlighted.titleTextAttributes = buttonAttributes - - // Apply the initial appearance to all states + configureButtonAppearance(initialAppearance, font: buttonFont) + awfulNavigationBar.standardAppearance = initialAppearance awfulNavigationBar.scrollEdgeAppearance = initialAppearance awfulNavigationBar.compactAppearance = initialAppearance awfulNavigationBar.compactScrollEdgeAppearance = initialAppearance - awfulNavigationBar.tintColor = textColor + awfulNavigationBar.tintColor = nil awfulNavigationBar.setNeedsLayout() awfulNavigationBar.layoutIfNeeded() @@ -416,16 +388,7 @@ final class NavigationController: UINavigationController, Themeable { 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 + configureButtonAppearance(appearance, font: buttonFont) awfulNavigationBar.standardAppearance = appearance awfulNavigationBar.scrollEdgeAppearance = appearance @@ -537,14 +500,17 @@ extension NavigationController: UINavigationControllerDelegate { awfulNavigationBar.tintColor = textColor - viewController.navigationItem.leftBarButtonItem?.tintColor = textColor - viewController.navigationItem.rightBarButtonItem?.tintColor = textColor - viewController.navigationItem.leftBarButtonItems?.forEach { $0.tintColor = textColor } - viewController.navigationItem.rightBarButtonItems?.forEach { $0.tintColor = textColor } + // Only set explicit bar button item colors on iOS < 26 + if #unavailable(iOS 26.0) { + viewController.navigationItem.leftBarButtonItem?.tintColor = textColor + viewController.navigationItem.rightBarButtonItem?.tintColor = textColor + viewController.navigationItem.leftBarButtonItems?.forEach { $0.tintColor = textColor } + viewController.navigationItem.rightBarButtonItems?.forEach { $0.tintColor = textColor } - if viewControllers.count > 1 { - let previousVC = viewControllers[viewControllers.count - 2] - previousVC.navigationItem.backBarButtonItem?.tintColor = textColor + if viewControllers.count > 1 { + let previousVC = viewControllers[viewControllers.count - 2] + previousVC.navigationItem.backBarButtonItem?.tintColor = textColor + } } } 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/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 6d6d782da..476ae8065 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -71,8 +71,8 @@ final class PostsPageViewController: ViewController { @available(iOS 26.0, *) func updateTitleViewTextColorForScrollProgress(_ progress: CGFloat) { if progress < 0.01 { - // At top: use theme color - liquidGlassTitleView?.textColor = theme["navigationBarTextColor"] + // At top: use mode-based color for proper contrast + liquidGlassTitleView?.textColor = theme["mode"] == "dark" ? .white : .black } else if progress > 0.99 { // Fully scrolled: use nil for dynamic color adaptation liquidGlassTitleView?.textColor = nil @@ -225,7 +225,7 @@ final class PostsPageViewController: ViewController { if #available(iOS 26.0, *) { let glassView = liquidGlassTitleView glassView?.title = title - glassView?.textColor = theme["navigationBarTextColor"] + glassView?.textColor = theme["mode"] == "dark" ? .white : .black switch UIDevice.current.userInterfaceIdiom { case .pad: @@ -1665,7 +1665,7 @@ final class PostsPageViewController: ViewController { if #available(iOS 26.0, *) { let glassView = liquidGlassTitleView // Set both text color and font from theme - glassView?.textColor = theme["navigationBarTextColor"] + glassView?.textColor = theme["mode"] == "dark" ? .white : .black switch UIDevice.current.userInterfaceIdiom { case .pad: @@ -1896,7 +1896,7 @@ final class PostsPageViewController: ViewController { navigationBar.compactScrollEdgeAppearance = appearance // CRITICAL: Set tintColor AFTER applying appearance to ensure back button uses theme color - let navTextColor: UIColor = theme["navigationBarTextColor"]! + let navTextColor: UIColor = theme["mode"] == "dark" ? .white : .black print("DEBUG: Setting navigationBar.tintColor to: \(navTextColor) for theme: \(theme["name"] ?? "unknown")") navigationBar.tintColor = navTextColor From ee2e6489606efcc1e02e6c07325c0609fade41a0 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:58:13 +1100 Subject: [PATCH 4/8] Removing the vertical page count button from postspageview toolbar as per feedback received. Still need a separate custom view in order to group the controls together with a shared glass background. --- .../Posts/PostsPageViewController.swift | 44 +++--- App/Views/PageNumberView.swift | 133 ++++++++++++++++ App/Views/VerticalPageNumberView.swift | 144 ------------------ Awful.xcodeproj/project.pbxproj | 8 +- 4 files changed, 156 insertions(+), 173 deletions(-) create mode 100644 App/Views/PageNumberView.swift delete mode 100644 App/Views/VerticalPageNumberView.swift diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 476ae8065..2df1de0f9 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -343,8 +343,8 @@ final class PostsPageViewController: ViewController { .nextUnread where self.posts.isEmpty: let pageCount = self.numberOfPages > 0 ? "\(self.numberOfPages)" : "?" if #available(iOS 26.0, *) { - self.verticalPageNumberView.currentPage = 0 - self.verticalPageNumberView.totalPages = self.numberOfPages > 0 ? self.numberOfPages : 0 + self.pageNumberView.currentPage = 0 + self.pageNumberView.totalPages = self.numberOfPages > 0 ? self.numberOfPages : 0 } else { self.currentPageItem.title = "Page ? of \(pageCount)" } @@ -399,8 +399,8 @@ final class PostsPageViewController: ViewController { let pageCount = self.numberOfPages > 0 ? "\(self.numberOfPages)" : "?" if #available(iOS 26.0, *) { // Use vertical view: show unknown current page with known total - self.verticalPageNumberView.currentPage = 0 // Will display as "?" - self.verticalPageNumberView.totalPages = self.numberOfPages > 0 ? self.numberOfPages : 0 + self.pageNumberView.currentPage = 0 // Will display as "?" + self.pageNumberView.totalPages = self.numberOfPages > 0 ? self.numberOfPages : 0 // iOS 26+ handles colors automatically } else { self.currentPageItem.title = "Page ? of \(pageCount)" @@ -620,8 +620,8 @@ final class PostsPageViewController: ViewController { return item }() - private lazy var verticalPageNumberView: VerticalPageNumberView = { - let view = VerticalPageNumberView() + private lazy var pageNumberView: PageNumberView = { + let view = PageNumberView() view.onTap = { [weak self] in self?.handlePageNumberTap() } @@ -641,16 +641,14 @@ final class PostsPageViewController: ViewController { // Set up the bar button item based on iOS version if #available(iOS 26.0, *) { - // Use vertical page number view for modern appearance wrapped in container for centering let containerView = UIView() - containerView.addSubview(verticalPageNumberView) - verticalPageNumberView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(pageNumberView) + pageNumberView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - verticalPageNumberView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), - verticalPageNumberView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - containerView.widthAnchor.constraint(equalTo: verticalPageNumberView.widthAnchor, constant: 6), // 3 points padding on each side - // Add a bit of vertical padding so the content appears visually centered in the toolbar - containerView.heightAnchor.constraint(equalTo: verticalPageNumberView.heightAnchor, constant: 5) + 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 { @@ -784,15 +782,11 @@ final class PostsPageViewController: ViewController { }() if case .specific(let pageNumber)? = page, numberOfPages > 0 { - // Update page display based on iOS version if #available(iOS 26.0, *) { - // Use vertical page number view for modern appearance - verticalPageNumberView.currentPage = pageNumber - verticalPageNumberView.totalPages = numberOfPages - // iOS 26+ handles colors automatically + pageNumberView.currentPage = pageNumber + pageNumberView.totalPages = numberOfPages currentPageItem.accessibilityLabel = "Page \(pageNumber) of \(numberOfPages)" } else { - // Use traditional text title for iOS 18 and below currentPageItem.title = "\(pageNumber) / \(numberOfPages)" currentPageItem.accessibilityLabel = "Page \(pageNumber) of \(numberOfPages)" currentPageItem.setTitleTextAttributes([.font: UIFont.preferredFontForTextStyle(.body, weight: .regular)], for: .normal) @@ -800,8 +794,8 @@ final class PostsPageViewController: ViewController { } else { // Clear page display if #available(iOS 26.0, *) { - verticalPageNumberView.currentPage = 0 - verticalPageNumberView.totalPages = 0 + pageNumberView.currentPage = 0 + pageNumberView.totalPages = 0 } else { currentPageItem.title = "" } @@ -879,8 +873,8 @@ final class PostsPageViewController: ViewController { // For popover presentation with custom view, we need to set sourceView and sourceRect if let popover = selectotron.popoverPresentationController { - popover.sourceView = verticalPageNumberView - popover.sourceRect = verticalPageNumberView.bounds + popover.sourceView = pageNumberView + popover.sourceRect = pageNumberView.bounds } } @@ -1732,7 +1726,7 @@ final class PostsPageViewController: ViewController { backItem.tintColor = theme["toolbarTextColor"] forwardItem.tintColor = theme["toolbarTextColor"] settingsItem.tintColor = theme["toolbarTextColor"] - verticalPageNumberView.textColor = theme["toolbarTextColor"] ?? UIColor.systemBlue + pageNumberView.textColor = theme["toolbarTextColor"] ?? UIColor.systemBlue } // Update toolbar items to refresh the actions button diff --git a/App/Views/PageNumberView.swift b/App/Views/PageNumberView.swift new file mode 100644 index 000000000..49b91735e --- /dev/null +++ b/App/Views/PageNumberView.swift @@ -0,0 +1,133 @@ +// PageNumberView.swift +// +// Copyright © 2025 Awful Contributors. All rights reserved. +// + +import UIKit + +final class PageNumberView: UIView { + private let pageLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.font = UIFont.preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + return label + }() + + var currentPage: Int = 1 { + didSet { + updateDisplay() + } + } + + var totalPages: Int = 1 { + didSet { + updateDisplay() + } + } + + var textColor: UIColor = { + return .label + }() { + didSet { + updateColors() + } + } + + var onTap: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + updateDisplay() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + updateDisplay() + } + + private func setupViews() { + addSubview(pageLabel) + isUserInteractionEnabled = true + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tapGesture) + + NSLayoutConstraint.activate([ + pageLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + pageLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + pageLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), + pageLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + widthAnchor.constraint(greaterThanOrEqualToConstant: 60), + heightAnchor.constraint(equalToConstant: { + if #available(iOS 26.0, *) { + return 39 + } else { + return 44 + } + }()) + ]) + + pageLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + updateColors() + } + + @objc private func handleTap() { + onTap?() + } + + private func updateDisplay() { + let formatter = NumberFormatter() + formatter.numberStyle = .none + formatter.usesGroupingSeparator = false + + let currentPageText: String + if currentPage == 0 { + currentPageText = "?" + } else { + currentPageText = formatter.string(from: NSNumber(value: currentPage)) ?? "\(currentPage)" + } + + let totalPagesText: String + if totalPages == 0 { + totalPagesText = "?" + } else { + totalPagesText = formatter.string(from: NSNumber(value: totalPages)) ?? "\(totalPages)" + } + + pageLabel.text = "\(currentPageText) / \(totalPagesText)" + accessibilityLabel = "Page \(currentPageText) of \(totalPagesText)" + accessibilityHint = "Opens page picker" + } + + private func updateColors() { + pageLabel.textColor = textColor + } + + override var intrinsicContentSize: CGSize { + let labelSize = pageLabel.intrinsicContentSize + let height: CGFloat + if #available(iOS 26.0, *) { + height = 39 + } else { + height = 44 + } + return CGSize(width: max(labelSize.width, 60), height: height) + } + + override func layoutSubviews() { + super.layoutSubviews() + invalidateIntrinsicContentSize() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { + invalidateIntrinsicContentSize() + } + } +} diff --git a/App/Views/VerticalPageNumberView.swift b/App/Views/VerticalPageNumberView.swift deleted file mode 100644 index b03cb8aae..000000000 --- a/App/Views/VerticalPageNumberView.swift +++ /dev/null @@ -1,144 +0,0 @@ -// VerticalPageNumberView.swift -// -// Copyright © 2025 Awful Contributors. All rights reserved. -// - -import UIKit - -final class VerticalPageNumberView: UIView { - private let currentPageLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textAlignment = .center - label.font = UIFont.preferredFontForTextStyle(.footnote, fontName: nil, sizeAdjustment: 0, weight: .medium) - label.adjustsFontForContentSizeCategory = true - return label - }() - - private let totalPagesLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textAlignment = .center - label.font = UIFont.preferredFontForTextStyle(.footnote, fontName: nil, sizeAdjustment: 0, weight: .medium) - label.adjustsFontForContentSizeCategory = true - return label - }() - - private let separatorLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.text = " /" - label.textAlignment = .center - label.font = UIFont.preferredFontForTextStyle(.footnote, fontName: nil, sizeAdjustment: 0, weight: .medium) - label.adjustsFontForContentSizeCategory = true - return label - }() - private let interRowSpacing: CGFloat = -2 - - var currentPage: Int = 1 { - didSet { - updateDisplay() - } - } - - var totalPages: Int = 1 { - didSet { - updateDisplay() - } - } - - var textColor: UIColor = { - return .label - }() { - didSet { - updateColors() - } - } - var onTap: (() -> Void)? - override init(frame: CGRect) { - super.init(frame: frame) - setupViews() - updateDisplay() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupViews() - updateDisplay() - } - private func setupViews() { - addSubview(currentPageLabel) - addSubview(totalPagesLabel) - addSubview(separatorLabel) - isUserInteractionEnabled = true - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tapGesture) - NSLayoutConstraint.activate([ - currentPageLabel.topAnchor.constraint(equalTo: topAnchor), - currentPageLabel.centerXAnchor.constraint(equalTo: centerXAnchor), - totalPagesLabel.bottomAnchor.constraint(equalTo: bottomAnchor), - totalPagesLabel.centerXAnchor.constraint(equalTo: currentPageLabel.centerXAnchor), - currentPageLabel.widthAnchor.constraint(equalTo: totalPagesLabel.widthAnchor), - separatorLabel.centerYAnchor.constraint(equalTo: currentPageLabel.centerYAnchor), - separatorLabel.leadingAnchor.constraint(equalTo: currentPageLabel.trailingAnchor), - totalPagesLabel.topAnchor.constraint(equalTo: currentPageLabel.bottomAnchor, constant: interRowSpacing), - widthAnchor.constraint(greaterThanOrEqualToConstant: 40), - leadingAnchor.constraint(lessThanOrEqualTo: currentPageLabel.leadingAnchor), - trailingAnchor.constraint(greaterThanOrEqualTo: separatorLabel.trailingAnchor) - ]) - currentPageLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - totalPagesLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - separatorLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - - updateColors() - } - @objc private func handleTap() { - onTap?() - } - private func updateDisplay() { - let formatter = NumberFormatter() - formatter.numberStyle = .none - formatter.usesGroupingSeparator = false - if currentPage == 0 { - currentPageLabel.text = "?" - } else { - currentPageLabel.text = formatter.string(from: NSNumber(value: currentPage)) ?? "\(currentPage)" - } - if totalPages == 0 { - totalPagesLabel.text = "?" - } else { - totalPagesLabel.text = formatter.string(from: NSNumber(value: totalPages)) ?? "\(totalPages)" - } - let currentPageText = currentPage == 0 ? "?" : "\(currentPage)" - let totalPagesText = totalPages == 0 ? "?" : "\(totalPages)" - accessibilityLabel = "Page \(currentPageText) of \(totalPagesText)" - accessibilityHint = "Opens page picker" - } - - private func updateColors() { - currentPageLabel.textColor = textColor - totalPagesLabel.textColor = textColor - separatorLabel.textColor = textColor - } - override var intrinsicContentSize: CGSize { - let currentSize = currentPageLabel.intrinsicContentSize - let totalSize = totalPagesLabel.intrinsicContentSize - let separatorSize = separatorLabel.intrinsicContentSize - let width = max(currentSize.width, totalSize.width) + separatorSize.width - let height = currentSize.height + totalSize.height + interRowSpacing - - return CGSize(width: width, height: height) - } - - override func layoutSubviews() { - super.layoutSubviews() - invalidateIntrinsicContentSize() - } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { - invalidateIntrinsicContentSize() - } - } -} diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index 4fadc6a72..444fbd5a3 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -205,8 +205,8 @@ 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 */; }; - 2D62DEAA2EBFED4B00F7121B /* VerticalPageNumberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA92EBFED4800F7121B /* VerticalPageNumberView.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 */; }; @@ -524,8 +524,8 @@ 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 = ""; }; - 2D62DEA92EBFED4800F7121B /* VerticalPageNumberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalPageNumberView.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 = ""; }; @@ -1128,7 +1128,7 @@ 1CF7FACB1BB60C2200A077F2 /* Views */ = { isa = PBXGroup; children = ( - 2D62DEA92EBFED4800F7121B /* VerticalPageNumberView.swift */, + 2D939F222EC48FDE00F3464B /* PageNumberView.swift */, 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */, 2D10A3842E05C35700544F91 /* SearchView.swift */, 1CC22AB419F972C200D5BABD /* HairlineView.swift */, @@ -1547,7 +1547,6 @@ buildActionMask = 2147483647; files = ( 1CD005CF1BB734E900232FFD /* BookmarksTableViewController.swift in Sources */, - 2D62DEAA2EBFED4B00F7121B /* VerticalPageNumberView.swift in Sources */, 1C29BD55225121F100E1217A /* RootTabBarController.swift in Sources */, 1C40796A1A228DA6004A082F /* CopyURLActivity.swift in Sources */, 83410EF219A582B8002CD019 /* DateFormatters.swift in Sources */, @@ -1611,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 */, From 590ce5464e7f653f5e68ac9cd24d19625b9e7aef Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:07:28 +1100 Subject: [PATCH 5/8] Removing unnecessary comments --- .../Posts/PostsPageViewController.swift | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 2df1de0f9..6f834d7e7 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -398,10 +398,8 @@ final class PostsPageViewController: ViewController { .nextUnread where self.posts.isEmpty: let pageCount = self.numberOfPages > 0 ? "\(self.numberOfPages)" : "?" if #available(iOS 26.0, *) { - // Use vertical view: show unknown current page with known total - self.pageNumberView.currentPage = 0 // Will display as "?" + self.pageNumberView.currentPage = 0 self.pageNumberView.totalPages = self.numberOfPages > 0 ? self.numberOfPages : 0 - // iOS 26+ handles colors automatically } else { self.currentPageItem.title = "Page ? of \(pageCount)" } @@ -495,7 +493,6 @@ final class PostsPageViewController: ViewController { item.accessibilityLabel = NSLocalizedString("compose.accessibility-label", comment: "") // Only set explicit tint color for iOS < 26 if #available(iOS 26.0, *) { - // Let iOS 26+ handle the color automatically } else { item.tintColor = theme["navigationBarTextColor"] } @@ -592,7 +589,6 @@ final class PostsPageViewController: ViewController { item.accessibilityLabel = "Settings" // Only set explicit tint color for iOS < 26 if #available(iOS 26.0, *) { - // Let iOS 26+ handle the color automatically } else { item.tintColor = theme["toolbarTextColor"] } @@ -613,7 +609,6 @@ final class PostsPageViewController: ViewController { item.accessibilityLabel = "Previous page" // Only set explicit tint color for iOS < 26 if #available(iOS 26.0, *) { - // Let iOS 26+ handle the color automatically } else { item.tintColor = theme["toolbarTextColor"] } @@ -638,8 +633,7 @@ final class PostsPageViewController: ViewController { popover.barButtonItem = action.sender as? UIBarButtonItem } }) - - // Set up the bar button item based on iOS version + if #available(iOS 26.0, *) { let containerView = UIView() containerView.addSubview(pageNumberView) @@ -652,7 +646,6 @@ final class PostsPageViewController: ViewController { ]) item.customView = containerView } else { - // Use traditional text title for iOS 18 and below item.possibleTitles = ["2345 / 2345"] } @@ -674,7 +667,6 @@ final class PostsPageViewController: ViewController { item.accessibilityLabel = "Next page" // Only set explicit tint color for iOS < 26 if #available(iOS 26.0, *) { - // Let iOS 26+ handle the color automatically } else { item.tintColor = theme["toolbarTextColor"] } @@ -706,7 +698,6 @@ final class PostsPageViewController: ViewController { item.accessibilityLabel = "Thread actions" // Only set explicit tint color for iOS < 26 if #available(iOS 26.0, *) { - // Let iOS 26+ handle the color automatically } else { item.tintColor = theme["toolbarTextColor"] } @@ -792,7 +783,6 @@ final class PostsPageViewController: ViewController { currentPageItem.setTitleTextAttributes([.font: UIFont.preferredFontForTextStyle(.body, weight: .regular)], for: .normal) } } else { - // Clear page display if #available(iOS 26.0, *) { pageNumberView.currentPage = 0 pageNumberView.totalPages = 0 @@ -870,8 +860,7 @@ final class PostsPageViewController: ViewController { 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 @@ -1687,7 +1676,6 @@ final class PostsPageViewController: ViewController { // Update navigation bar button colors (only for iOS < 26) if #available(iOS 26.0, *) { - // Let iOS 26+ handle colors automatically } else { composeItem.tintColor = theme["navigationBarTextColor"] // Ensure the navigation bar itself uses the correct tint color for the back button @@ -1718,18 +1706,15 @@ final class PostsPageViewController: ViewController { postsView.toolbar.compactAppearance = appearance postsView.toolbar.scrollEdgeAppearance = appearance postsView.toolbar.compactScrollEdgeAppearance = appearance - - // Update toolbar button text colors (only for iOS < 26) + if #available(iOS 26.0, *) { - // Let iOS 26+ handle colors automatically } else { backItem.tintColor = theme["toolbarTextColor"] forwardItem.tintColor = theme["toolbarTextColor"] settingsItem.tintColor = theme["toolbarTextColor"] pageNumberView.textColor = theme["toolbarTextColor"] ?? UIColor.systemBlue } - - // Update toolbar items to refresh the actions button + updateToolbarItems() messageViewController?.themeDidChange() From 69f86f6d44abf75a56b9a73f0ae8b8ef433a8dbb Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:09:27 +1100 Subject: [PATCH 6/8] PageNumberView label was not following theme's roundedFont settings --- App/Views/PageNumberView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/Views/PageNumberView.swift b/App/Views/PageNumberView.swift index 49b91735e..f7c6fd96f 100644 --- a/App/Views/PageNumberView.swift +++ b/App/Views/PageNumberView.swift @@ -10,7 +10,7 @@ final class PageNumberView: UIView { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textAlignment = .center - label.font = UIFont.preferredFont(forTextStyle: .body) + label.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: 0, weight: .regular) label.adjustsFontForContentSizeCategory = true return label }() From 3e6290fec4f680d60683f4dad094e2161bbd40b7 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:24:35 +1100 Subject: [PATCH 7/8] Update PageNumberView to change font style when theme changes --- App/View Controllers/Posts/PostsPageViewController.swift | 2 ++ App/Views/PageNumberView.swift | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/App/View Controllers/Posts/PostsPageViewController.swift b/App/View Controllers/Posts/PostsPageViewController.swift index 6f834d7e7..081b8c286 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -1715,6 +1715,8 @@ final class PostsPageViewController: ViewController { pageNumberView.textColor = theme["toolbarTextColor"] ?? UIColor.systemBlue } + pageNumberView.updateTheme() + updateToolbarItems() messageViewController?.themeDidChange() diff --git a/App/Views/PageNumberView.swift b/App/Views/PageNumberView.swift index f7c6fd96f..16c17ec3d 100644 --- a/App/Views/PageNumberView.swift +++ b/App/Views/PageNumberView.swift @@ -107,6 +107,10 @@ final class PageNumberView: UIView { pageLabel.textColor = textColor } + func updateTheme() { + pageLabel.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: 0, weight: .regular) + } + override var intrinsicContentSize: CGSize { let labelSize = pageLabel.intrinsicContentSize let height: CGFloat From da6318d0a294846223e186e9cd5ac8f6a11062fd Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:26:11 +1100 Subject: [PATCH 8/8] Refactoring to add improvements to same files also in the immersion-mode-final branch (which should be merged after this branch) --- App/Navigation/NavigationController.swift | 102 +++++----- App/View Controllers/Posts/GradientView.swift | 4 +- .../PostsPageSettingsViewController.swift | 35 ++-- .../Posts/PostsPageTopBar.swift | 10 +- .../Posts/PostsPageTopBarLiquidGlass.swift | 5 +- .../Posts/PostsPageView.swift | 183 ++++++++++-------- .../Posts/PostsPageViewController.swift | 97 ++++------ App/Views/LiquidGlassTitleView.swift | 32 +-- App/Views/PageNumberView.swift | 39 ++-- 9 files changed, 269 insertions(+), 238 deletions(-) diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index d20a45001..dfd93a0d5 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -21,6 +21,8 @@ final class NavigationController: UINavigationController, Themeable { 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 } @@ -54,7 +56,7 @@ final class NavigationController: UINavigationController, Themeable { } @available(iOS 26.0, *) - private func createGradientBackgroundImage(from color: UIColor, size: CGSize = CGSize(width: 1, height: 96)) -> UIImage? { + private func createGradientBackgroundImage(from color: UIColor, size: CGSize = gradientImageSize) -> UIImage? { let format = UIGraphicsImageRendererFormat() format.opaque = false let renderer = UIGraphicsImageRenderer(size: size, format: format) @@ -222,6 +224,17 @@ final class NavigationController: UINavigationController, Themeable { private func updateNavigationBarBackgroundWithProgress(_ progress: CGFloat) { let appearance = UINavigationBarAppearance() + configureBackground(for: appearance, progress: progress) + configureBackIndicator(for: appearance, progress: progress) + configureTitleAndButtons(for: appearance, progress: progress) + applyAppearance(appearance, progress: progress) + } + + @available(iOS 26.0, *) + private func configureBackground(for appearance: UINavigationBarAppearance, progress: CGFloat) { + appearance.shadowColor = nil + appearance.shadowImage = nil + if progress < ScrollProgress.atTop { appearance.configureWithOpaqueBackground() appearance.backgroundColor = theme["navigationBarTintColor"] @@ -232,8 +245,10 @@ final class NavigationController: UINavigationController, Themeable { } else { appearance.configureWithTransparentBackground() - let opaqueColor: UIColor = theme["navigationBarTintColor"]! - let gradientBaseColor: UIColor = theme["listHeaderBackgroundColor"]! + guard let opaqueColor = theme[uicolor: "navigationBarTintColor"], + let gradientBaseColor = theme[uicolor: "listHeaderBackgroundColor"] else { + return + } if let gradientImage = createGradientBackgroundImage(from: gradientBaseColor) { appearance.backgroundImage = gradientImage @@ -243,10 +258,10 @@ final class NavigationController: UINavigationController, Themeable { appearance.backgroundColor = interpolateColor(from: opaqueColor, to: gradientBaseColor, progress: progress) } } + } - appearance.shadowColor = nil - appearance.shadowImage = nil - + @available(iOS 26.0, *) + private func configureBackIndicator(for appearance: UINavigationBarAppearance, progress: CGFloat) { if progress > ScrollProgress.fullyScrolled { if let backImage = UIImage(named: "back")?.withTintColor(.label, renderingMode: .alwaysOriginal) { appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) @@ -256,37 +271,28 @@ final class NavigationController: UINavigationController, Themeable { appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } } + } - if progress < ScrollProgress.atTop { - let textColor: UIColor = theme["navigationBarTextColor"]! - - appearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) - ] - let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) - configureButtonAppearance(appearance, font: buttonFont) + @available(iOS 26.0, *) + private func configureTitleAndButtons(for appearance: UINavigationBarAppearance, progress: CGFloat) { + let textColor: UIColor + if progress > ScrollProgress.fullyScrolled { + textColor = theme["mode"] == "dark" ? .white : .black + } else { + textColor = theme[uicolor: "navigationBarTextColor"] ?? .label } - else if progress > ScrollProgress.fullyScrolled { - let textColor: UIColor = theme["mode"] == "dark" ? .white : .black - appearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) - ] - let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) - configureButtonAppearance(appearance, font: buttonFont) - } else { - let textColor: UIColor = theme["navigationBarTextColor"]! + appearance.titleTextAttributes = [ + .foregroundColor: textColor, + .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + ] - appearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) - ] - let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) - configureButtonAppearance(appearance, font: buttonFont) - } + let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) + configureButtonAppearance(appearance, font: buttonFont) + } + @available(iOS 26.0, *) + private func applyAppearance(_ appearance: UINavigationBarAppearance, progress: CGFloat) { awfulNavigationBar.standardAppearance = appearance awfulNavigationBar.scrollEdgeAppearance = appearance awfulNavigationBar.compactAppearance = appearance @@ -305,8 +311,13 @@ final class NavigationController: UINavigationController, Themeable { var startRed: CGFloat = 0, startGreen: CGFloat = 0, startBlue: CGFloat = 0, startAlpha: CGFloat = 0 var endRed: CGFloat = 0, endGreen: CGFloat = 0, endBlue: CGFloat = 0, endAlpha: CGFloat = 0 - startColor.getRed(&startRed, green: &startGreen, blue: &startBlue, alpha: &startAlpha) - endColor.getRed(&endRed, green: &endGreen, blue: &endBlue, alpha: &endAlpha) + // Convert colors to RGB color space if needed and handle failures + guard startColor.getRed(&startRed, green: &startGreen, blue: &startBlue, alpha: &startAlpha), + endColor.getRed(&endRed, green: &endGreen, blue: &endBlue, alpha: &endAlpha) else { + // If color conversion fails (e.g., non-RGB color space), return the end color at full progress + // or start color at zero progress + return progress >= 0.5 ? endColor : startColor + } let red = startRed + (endRed - startRed) * progress let green = startGreen + (endGreen - startGreen) * progress @@ -318,15 +329,13 @@ final class NavigationController: UINavigationController, Themeable { private func updateNavigationBarAppearance(with theme: Theme, for viewController: UIViewController? = nil) { awfulNavigationBar.barTintColor = theme["navigationBarTintColor"] - - // iOS 26: Hide bottom border for liquid glass effect, earlier versions show themed border + if #available(iOS 26.0, *) { awfulNavigationBar.bottomBorderColor = .clear } else { awfulNavigationBar.bottomBorderColor = theme["topBarBottomBorderColor"] } - - // iOS 26: Remove shadow for liquid glass effect, earlier versions use themed shadow + if #available(iOS 26.0, *) { awfulNavigationBar.layer.shadowOpacity = 0 awfulNavigationBar.layer.shadowColor = UIColor.clear.cgColor @@ -349,15 +358,15 @@ final class NavigationController: UINavigationController, Themeable { initialAppearance.shadowColor = nil initialAppearance.shadowImage = nil - let textColor: UIColor = theme["navigationBarTextColor"]! + let textColor = theme[uicolor: "navigationBarTextColor"] ?? .label if let backImage = UIImage(named: "back")?.withRenderingMode(.alwaysTemplate) { initialAppearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } initialAppearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) + .foregroundColor: textColor, + .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) ] let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) configureButtonAppearance(initialAppearance, font: buttonFont) @@ -383,9 +392,9 @@ final class NavigationController: UINavigationController, Themeable { appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } - let textColor: UIColor = theme["navigationBarTextColor"]! - appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: textColor, - NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold)] + let textColor = theme[uicolor: "navigationBarTextColor"] ?? .label + appearance.titleTextAttributes = [.foregroundColor: textColor, + .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold)] let buttonFont = UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) configureButtonAppearance(appearance, font: buttonFont) @@ -401,7 +410,7 @@ final class NavigationController: UINavigationController, Themeable { awfulNavigationBar.layoutIfNeeded() } } else { - let fallbackTextColor = theme[uicolor: "navigationBarTextColor"]! + guard let fallbackTextColor = theme[uicolor: "navigationBarTextColor"] else { return } let attrs: [NSAttributedString.Key: Any] = [ .foregroundColor: fallbackTextColor, .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular) @@ -496,11 +505,10 @@ extension NavigationController: UINavigationControllerDelegate { } if !isScrolledFromTop { - let textColor: UIColor = vcTheme["navigationBarTextColor"]! + guard let textColor = vcTheme[uicolor: "navigationBarTextColor"] else { return } awfulNavigationBar.tintColor = textColor - // Only set explicit bar button item colors on iOS < 26 if #unavailable(iOS 26.0) { viewController.navigationItem.leftBarButtonItem?.tintColor = textColor viewController.navigationItem.rightBarButtonItem?.tintColor = textColor diff --git a/App/View Controllers/Posts/GradientView.swift b/App/View Controllers/Posts/GradientView.swift index 8f9aec20b..44cb8b4b0 100644 --- a/App/View Controllers/Posts/GradientView.swift +++ b/App/View Controllers/Posts/GradientView.swift @@ -11,8 +11,8 @@ final class GradientView: UIView { override class var layerClass: AnyClass { CAGradientLayer.self } - - var gradientLayer: CAGradientLayer { + + private var gradientLayer: CAGradientLayer { layer as! CAGradientLayer } diff --git a/App/View Controllers/Posts/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 317912685..8ff532a05 100644 --- a/App/View Controllers/Posts/PostsPageTopBar.swift +++ b/App/View Controllers/Posts/PostsPageTopBar.swift @@ -8,6 +8,8 @@ import UIKit final class PostsPageTopBar: UIView, PostsPageTopBarProtocol { + private static let buttonFont = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .regular) + private lazy var stackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [parentForumButton, previousPostsButton, scrollToEndButton]) stackView.distribution = .fillEqually @@ -19,7 +21,7 @@ final class PostsPageTopBar: UIView, PostsPageTopBarProtocol { parentForumButton.accessibilityLabel = LocalizedString("posts-page.parent-forum-button.accessibility-label") parentForumButton.accessibilityHint = LocalizedString("posts-page.parent-forum-button.accessibility-hint") parentForumButton.setTitle(LocalizedString("posts-page.parent-forum-button.title"), for: .normal) - parentForumButton.titleLabel?.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .regular) + parentForumButton.titleLabel?.font = Self.buttonFont return parentForumButton }() @@ -27,15 +29,15 @@ final class PostsPageTopBar: UIView, PostsPageTopBarProtocol { let previousPostsButton = UIButton(type: .system) previousPostsButton.accessibilityLabel = LocalizedString("posts-page.previous-posts-button.accessibility-label") previousPostsButton.setTitle(LocalizedString("posts-page.previous-posts-button.title"), for: .normal) - previousPostsButton.titleLabel?.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .regular) + previousPostsButton.titleLabel?.font = Self.buttonFont return previousPostsButton }() - private let scrollToEndButton: UIButton = { + private lazy var scrollToEndButton: UIButton = { let scrollToEndButton = UIButton(type: .system) scrollToEndButton.accessibilityLabel = LocalizedString("posts-page.scroll-to-end-button.accessibility-label") scrollToEndButton.setTitle(LocalizedString("posts-page.scroll-to-end-button.title"), for: .normal) - scrollToEndButton.titleLabel?.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .regular) + scrollToEndButton.titleLabel?.font = Self.buttonFont return scrollToEndButton }() diff --git a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift index b29041a81..ac31eb5b1 100644 --- a/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift +++ b/App/View Controllers/Posts/PostsPageTopBarLiquidGlass.swift @@ -6,7 +6,6 @@ import AwfulSettings import AwfulTheming import UIKit - @available(iOS 26.0, *) final class PostsPageTopBarLiquidGlass: UIView, PostsPageTopBarProtocol { @@ -175,9 +174,7 @@ final class PostsPageTopBarLiquidGlass: UIView, PostsPageTopBarProtocol { var scrollToEnd: (() -> Void)? { didSet { updateButtonsEnabled() } } - - // MARK: Gunk - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/App/View Controllers/Posts/PostsPageView.swift b/App/View Controllers/Posts/PostsPageView.swift index ca07f88d6..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), @@ -175,10 +176,28 @@ final class PostsPageView: UIView { // MARK: Top bar var topBar: PostsPageTopBarProtocol { - return topBarContainer.topBar + 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,10 +235,9 @@ 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 safeAreaGradientView: GradientView = { + private lazy var fallbackSafeAreaGradientView: GradientView = { let view = GradientView() view.isUserInteractionEnabled = false - // Show gradient only on iOS 26+ if #available(iOS 26.0, *) { view.alpha = 1.0 view.isHidden = false @@ -247,7 +265,7 @@ final class PostsPageView: UIView { toolbar.overrideUserInterfaceStyle = Theme.defaultTheme()["mode"] == "light" ? .light : .dark addSubview(renderView) - addSubview(safeAreaGradientView) // Add gradient before top bar so it appears behind + addSubview(fallbackSafeAreaGradientView) addSubview(topBarContainer) addSubview(loadingViewContainer) addSubview(toolbar) @@ -265,25 +283,15 @@ final class PostsPageView: UIView { renderView.frame = bounds loadingViewContainer.frame = bounds - // Position gradient view in the status bar area only - if #available(iOS 26.0, *) { - // Position gradient to cover only the top safe area (status bar/notch) - // Use actual device safe area top instead of layoutMargins to prevent extending into content - let gradientHeight: CGFloat = window?.safeAreaInsets.top ?? safeAreaInsets.top - safeAreaGradientView.frame = CGRect( + if toolbar.transform == .identity { + let toolbarHeight = toolbar.sizeThatFits(bounds.size).height + toolbar.frame = CGRect( x: bounds.minX, - y: bounds.minY, // Start at top of screen + y: bounds.maxY - layoutMargins.bottom - toolbarHeight, width: bounds.width, - height: gradientHeight) + height: toolbarHeight) } - 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 let refreshControlHeight = refreshControlContainer.layoutFittingCompressedHeight(targetWidth: bounds.width) @@ -303,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 @@ -349,9 +365,7 @@ final class PostsPageView: UIView { renderView.scrollView.indicatorStyle = theme.scrollIndicatorStyle renderView.setThemeStylesheet(theme["postsViewCSS"] ?? "") - // Only set toolbar colors for iOS < 26 if #available(iOS 26.0, *) { - // Let iOS 26+ handle toolbar colors and borders automatically toolbar.isTranslucent = Theme.defaultTheme()[bool: "tabBarIsTranslucent"] ?? false } else { toolbar.tintColor = Theme.defaultTheme()["toolbarTextColor"]! @@ -360,11 +374,6 @@ final class PostsPageView: UIView { } topBar.themeDidChange(Theme.defaultTheme()) - - // Update gradient view for theme changes - if #available(iOS 26.0, *) { - safeAreaGradientView.themeDidChange() - } } // MARK: Gunk @@ -381,16 +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: PostsPageTopBarProtocol = { - let topBar: PostsPageTopBarProtocol - if #available(iOS 26.0, *) { - topBar = PostsPageTopBarLiquidGlass() - } else { - 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) { @@ -398,7 +415,6 @@ extension PostsPageView { clipsToBounds = true - // Use clear background for iOS 26+ to allow liquid glass effect if #available(iOS 26.0, *) { backgroundColor = .clear } @@ -506,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 { @@ -552,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 @@ -561,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. @@ -629,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. @@ -691,45 +726,35 @@ extension PostsPageView: ScrollViewDelegateExtras { } } - // Update navigation bar appearance for iOS 26+ based on scroll progress if #available(iOS 26.0, *) { - // Calculate scroll progress for smooth navbar 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 - } + updateNavigationBarForScrollProgress(scrollView) + } + } - // Find the parent view controller by walking up the responder chain - var responder: UIResponder? = self - while responder != nil { - if let viewController = responder as? PostsPageViewController, - let navController = viewController.navigationController as? NavigationController { - // NavigationController will handle the navbar tint color and back button - navController.updateNavigationBarTintForScrollProgress(NSNumber(value: Float(progress))) + @available(iOS 26.0, *) + private func updateNavigationBarForScrollProgress(_ scrollView: UIScrollView) { + let topInset = scrollView.adjustedContentInset.top + let currentOffset = scrollView.contentOffset.y + let topPosition = -topInset - // Update the liquid glass title view text color based on scroll progress - viewController.updateTitleViewTextColorForScrollProgress(progress) + let transitionDistance: CGFloat = 30.0 - break - } - responder = responder?.next - } + 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 081b8c286..f3728a27e 100644 --- a/App/View Controllers/Posts/PostsPageViewController.swift +++ b/App/View Controllers/Posts/PostsPageViewController.swift @@ -60,22 +60,23 @@ final class PostsPageViewController: ViewController { private var _liquidGlassTitleView: UIView? @available(iOS 26.0, *) - private var liquidGlassTitleView: LiquidGlassTitleView? { - if _liquidGlassTitleView == nil { - _liquidGlassTitleView = LiquidGlassTitleView() + private var liquidGlassTitleView: LiquidGlassTitleView { + if let existingView = _liquidGlassTitleView as? LiquidGlassTitleView { + return existingView } - return _liquidGlassTitleView as? LiquidGlassTitleView + let newView = LiquidGlassTitleView() + _liquidGlassTitleView = newView + return newView } - /// Updates the title view text color based on scroll position for dynamic adaptation @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 { - // At top: use mode-based color for proper contrast - liquidGlassTitleView?.textColor = theme["mode"] == "dark" ? .white : .black + liquidGlassTitleView.textColor = theme["mode"] == "dark" ? .white : .black } else if progress > 0.99 { - // Fully scrolled: use nil for dynamic color adaptation - liquidGlassTitleView?.textColor = nil + liquidGlassTitleView.textColor = nil } } @@ -119,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() } @@ -224,15 +226,9 @@ final class PostsPageViewController: ViewController { didSet { if #available(iOS 26.0, *) { let glassView = liquidGlassTitleView - glassView?.title = title - glassView?.textColor = theme["mode"] == "dark" ? .white : .black - - switch UIDevice.current.userInterfaceIdiom { - case .pad: - glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) - default: - glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) - } + glassView.title = title + glassView.textColor = theme["mode"] == "dark" ? .white : .black + glassView.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) navigationItem.titleView = glassView @@ -342,12 +338,7 @@ final class PostsPageViewController: ViewController { case .last where self.posts.isEmpty, .nextUnread where self.posts.isEmpty: let pageCount = self.numberOfPages > 0 ? "\(self.numberOfPages)" : "?" - 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)" - } + self.currentPageItem.title = "Page ? of \(pageCount)" case .last, .nextUnread, .specific: break @@ -673,7 +664,6 @@ final class PostsPageViewController: ViewController { return item }() - private func actionsItem() -> UIBarButtonItem { // Use primaryAction like the other toolbar buttons let item = UIBarButtonItem(primaryAction: UIAction( @@ -727,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)") } } @@ -783,6 +773,7 @@ final class PostsPageViewController: ViewController { currentPageItem.setTitleTextAttributes([.font: UIFont.preferredFontForTextStyle(.body, weight: .regular)], for: .normal) } } else { + // Clear page display if #available(iOS 26.0, *) { pageNumberView.currentPage = 0 pageNumberView.totalPages = 0 @@ -855,18 +846,19 @@ final class PostsPageViewController: ViewController { popover.barButtonItem = sender } } - + 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() @@ -1648,30 +1640,21 @@ final class PostsPageViewController: ViewController { if #available(iOS 26.0, *) { let glassView = liquidGlassTitleView // Set both text color and font from theme - glassView?.textColor = theme["mode"] == "dark" ? .white : .black - - switch UIDevice.current.userInterfaceIdiom { - case .pad: - glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) - default: - glassView?.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) - } + glassView.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"] - - switch UIDevice.current.userInterfaceIdiom { - case .pad: - navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPad"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPad"]!)!.weight) - navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! - default: - navigationItem.titleLabel.font = UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: theme[double: "postTitleFontSizeAdjustmentPhone"]!, weight: FontWeight(rawValue: theme["postTitleFontWeightPhone"]!)!.weight) + navigationItem.titleLabel.font = fontForPostTitle(from: theme, idiom: UIDevice.current.userInterfaceIdiom) + + if UIDevice.current.userInterfaceIdiom == .phone { navigationItem.titleLabel.numberOfLines = 2 - navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"]! } + + navigationItem.titleLabel.textColor = Theme.defaultTheme()[uicolor: "navigationBarTextColor"] ?? .label } // Update navigation bar button colors (only for iOS < 26) @@ -1828,7 +1811,19 @@ final class PostsPageViewController: ViewController { // See commentary in `viewDidLoad()` about our layout strategy here. tl;dr layout margins were the highest-level approach available on all versions of iOS that Awful supported, so we'll use them exclusively to represent the safe area. Probably not necessary anymore. postsView.layoutMargins = view.safeAreaInsets } - + + /// Safely retrieves font configuration from the theme with fallback defaults + private func fontForPostTitle(from theme: Theme, idiom: UIUserInterfaceIdiom) -> UIFont { + let sizeAdjustmentKey = idiom == .pad ? "postTitleFontSizeAdjustmentPad" : "postTitleFontSizeAdjustmentPhone" + let weightKey = idiom == .pad ? "postTitleFontWeightPad" : "postTitleFontWeightPhone" + + let sizeAdjustment = theme[double: sizeAdjustmentKey] ?? (idiom == .pad ? 0 : -1) + let weightString = theme[weightKey] ?? "semibold" + let weight = FontWeight(rawValue: weightString)?.weight ?? .semibold + + return UIFont.preferredFontForTextStyle(.callout, fontName: nil, sizeAdjustment: sizeAdjustment, weight: weight) + } + @available(iOS 26.0, *) private func configureNavigationBarForLiquidGlass() { guard let navigationBar = navigationController?.navigationBar else { return } @@ -1846,8 +1841,7 @@ final class PostsPageViewController: ViewController { appearance.shadowColor = nil appearance.shadowImage = nil - // Set initial text colors from theme - let textColor: UIColor = theme["navigationBarTextColor"]! + let textColor: UIColor = theme["navigationBarTextColor"] ?? .label appearance.titleTextAttributes = [ NSAttributedString.Key.foregroundColor: textColor, NSAttributedString.Key.font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .semibold) @@ -1876,26 +1870,21 @@ final class PostsPageViewController: ViewController { navigationBar.compactAppearance = appearance navigationBar.compactScrollEdgeAppearance = appearance - // CRITICAL: Set tintColor AFTER applying appearance to ensure back button uses theme color + // Set tintColor AFTER applying appearance to ensure back button uses theme color let navTextColor: UIColor = theme["mode"] == "dark" ? .white : .black - print("DEBUG: Setting navigationBar.tintColor to: \(navTextColor) for theme: \(theme["name"] ?? "unknown")") 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)) - // Force navigation bar to update its appearance navigationBar.setNeedsLayout() navigationBar.layoutIfNeeded() - // Try setting the back button tint directly on the previous view controller if let previousVC = navigationController?.viewControllers.dropLast().last { previousVC.navigationItem.backBarButtonItem?.tintColor = navTextColor } - // The NavigationController will handle the dynamic transition based on scroll position - // iOS 26 handles status bar style automatically with liquid glass } override func viewDidAppear(_ animated: Bool) { diff --git a/App/Views/LiquidGlassTitleView.swift b/App/Views/LiquidGlassTitleView.swift index f29d7776d..37af8f95b 100644 --- a/App/Views/LiquidGlassTitleView.swift +++ b/App/Views/LiquidGlassTitleView.swift @@ -7,13 +7,19 @@ import UIKit @available(iOS 26.0, *) final class LiquidGlassTitleView: UIView { + private static let lineSpacing: CGFloat = -2 + private static let horizontalPadding: CGFloat = 16 + private static let verticalPadding: CGFloat = 8 + private static let defaultWidth: CGFloat = 320 + private static let defaultHeight: CGFloat = 56 + private var visualEffectView: UIVisualEffectView = { let effect = UIGlassEffect() let view = UIVisualEffectView(effect: effect) view.translatesAutoresizingMaskIntoConstraints = false return view }() - + private let titleLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false @@ -51,17 +57,17 @@ final class LiquidGlassTitleView: UIView { private func updateTitleDisplay() { guard let text = titleLabel.text, !text.isEmpty else { return } - + let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = .center - paragraphStyle.lineSpacing = -2 + paragraphStyle.lineSpacing = Self.lineSpacing paragraphStyle.lineBreakMode = .byWordWrapping - + let attributes: [NSAttributedString.Key: Any] = [ .paragraphStyle: paragraphStyle, .font: titleLabel.font ?? UIFont.preferredFont(forTextStyle: .callout) ] - + titleLabel.attributedText = NSAttributedString(string: text, attributes: attributes) } @@ -84,11 +90,11 @@ final class LiquidGlassTitleView: UIView { visualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), visualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), visualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), - - titleLabel.topAnchor.constraint(equalTo: visualEffectView.contentView.topAnchor, constant: 8), - titleLabel.leadingAnchor.constraint(equalTo: visualEffectView.contentView.leadingAnchor, constant: 16), - titleLabel.trailingAnchor.constraint(equalTo: visualEffectView.contentView.trailingAnchor, constant: -16), - titleLabel.bottomAnchor.constraint(equalTo: visualEffectView.contentView.bottomAnchor, constant: -8) + + titleLabel.topAnchor.constraint(equalTo: visualEffectView.contentView.topAnchor, constant: Self.verticalPadding), + titleLabel.leadingAnchor.constraint(equalTo: visualEffectView.contentView.leadingAnchor, constant: Self.horizontalPadding), + titleLabel.trailingAnchor.constraint(equalTo: visualEffectView.contentView.trailingAnchor, constant: -Self.horizontalPadding), + titleLabel.bottomAnchor.constraint(equalTo: visualEffectView.contentView.bottomAnchor, constant: -Self.verticalPadding) ]) isAccessibilityElement = false @@ -106,10 +112,10 @@ final class LiquidGlassTitleView: UIView { } override var intrinsicContentSize: CGSize { - return CGSize(width: 320, height: 56) + return CGSize(width: Self.defaultWidth, height: Self.defaultHeight) } - + override func sizeThatFits(_ size: CGSize) -> CGSize { - return CGSize(width: 320, height: 56) + return CGSize(width: Self.defaultWidth, height: Self.defaultHeight) } } diff --git a/App/Views/PageNumberView.swift b/App/Views/PageNumberView.swift index 16c17ec3d..056823a7e 100644 --- a/App/Views/PageNumberView.swift +++ b/App/Views/PageNumberView.swift @@ -6,11 +6,24 @@ 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.preferredFontForTextStyle(.body, sizeAdjustment: 0, weight: .regular) + label.font = UIFont.preferredFont(forTextStyle: .body) label.adjustsFontForContentSizeCategory = true return label }() @@ -27,9 +40,7 @@ final class PageNumberView: UIView { } } - var textColor: UIColor = { - return .label - }() { + var textColor: UIColor = .label { didSet { updateColors() } @@ -60,14 +71,8 @@ final class PageNumberView: UIView { pageLabel.centerYAnchor.constraint(equalTo: centerYAnchor), pageLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), pageLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), - widthAnchor.constraint(greaterThanOrEqualToConstant: 60), - heightAnchor.constraint(equalToConstant: { - if #available(iOS 26.0, *) { - return 39 - } else { - return 44 - } - }()) + widthAnchor.constraint(greaterThanOrEqualToConstant: Self.minWidth), + heightAnchor.constraint(equalToConstant: Self.currentHeight) ]) pageLabel.setContentCompressionResistancePriority(.required, for: .horizontal) @@ -110,16 +115,10 @@ final class PageNumberView: UIView { func updateTheme() { pageLabel.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: 0, weight: .regular) } - + override var intrinsicContentSize: CGSize { let labelSize = pageLabel.intrinsicContentSize - let height: CGFloat - if #available(iOS 26.0, *) { - height = 39 - } else { - height = 44 - } - return CGSize(width: max(labelSize.width, 60), height: height) + return CGSize(width: max(labelSize.width, Self.minWidth), height: Self.currentHeight) } override func layoutSubviews() {