Skip to content
Merged
9 changes: 8 additions & 1 deletion App/Composition/CompositionViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
18 changes: 9 additions & 9 deletions App/Navigation/NavigationBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
358 changes: 340 additions & 18 deletions App/Navigation/NavigationController.swift

Large diffs are not rendered by default.

20 changes: 17 additions & 3 deletions App/Posts/ReplyWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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)
}

Expand Down
163 changes: 161 additions & 2 deletions App/View Controllers/Messages/MessageViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -284,8 +311,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)

Expand All @@ -297,6 +340,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"
Expand Down Expand Up @@ -395,6 +526,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 {
Expand Down
58 changes: 58 additions & 0 deletions App/View Controllers/Posts/GradientView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// GradientView.swift
//
// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US

import AwfulTheming
import UIKit

/// A UIView subclass that uses CAGradientLayer as its backing layer.
final class GradientView: UIView {

override class var layerClass: AnyClass {
CAGradientLayer.self
}

private var gradientLayer: CAGradientLayer {
layer as! CAGradientLayer
}

override init(frame: CGRect) {
super.init(frame: frame)
configureGradient()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
configureGradient()
}

private func configureGradient() {
let isDarkMode = Theme.defaultTheme()[string: "mode"] == "dark"

if isDarkMode {
gradientLayer.colors = [
UIColor.black.cgColor,
UIColor.black.withAlphaComponent(0.8).cgColor,
UIColor.black.withAlphaComponent(0.4).cgColor,
UIColor.clear.cgColor
]
gradientLayer.locations = [0.0, 0.3, 0.7, 1.0]
} else {
gradientLayer.colors = [
UIColor.white.withAlphaComponent(0.8).cgColor,
UIColor.white.withAlphaComponent(0.6).cgColor,
UIColor.white.withAlphaComponent(0.2).cgColor,
UIColor.white.withAlphaComponent(0.02).cgColor,
UIColor.clear.cgColor
]
gradientLayer.locations = [0.0, 0.4, 0.7, 0.9, 1.0]
}

gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
}

func themeDidChange() {
configureGradient()
}
}
22 changes: 22 additions & 0 deletions App/View Controllers/Posts/PostsPageRefreshSpinnerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
Loading
Loading