From 925e3d775b0032e56ed5fdecd367592b956ba11f Mon Sep 17 00:00:00 2001 From: Igor Solomennikov Date: Tue, 2 Dec 2025 15:31:03 -0800 Subject: [PATCH] Fixes/improvements for ConversationHeaderView. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • use one (larger) size of group/profile photo on iOS 26 in all device orientations (iOS doesn't make the navigation bar shorter anymore). • use ConversationHeaderView as as single source of text labels: when setting text as an attributed string, do not use set `color` attributes. • clean up the source code somewhat. --- Signal/ConversationView/CVViewState.swift | 2 +- .../ConversationHeaderView.swift | 156 ++++++++---------- ...ConversationViewController+Delegates.swift | 4 +- .../ConversationViewController+UI.swift | 92 +++++------ 4 files changed, 113 insertions(+), 141 deletions(-) diff --git a/Signal/ConversationView/CVViewState.swift b/Signal/ConversationView/CVViewState.swift index 56feacc02b..283c01cb0e 100644 --- a/Signal/ConversationView/CVViewState.swift +++ b/Signal/ConversationView/CVViewState.swift @@ -21,7 +21,7 @@ public class CVViewState: NSObject { public let threadUniqueId: String public var conversationStyle: ConversationStyle public var inputToolbar: ConversationInputToolbar? - public let headerView = ConversationHeaderView() + let headerView = ConversationHeaderView() public var bottomBarContainer = UIView.container() public var requestView: UIView? diff --git a/Signal/ConversationView/ConversationHeaderView.swift b/Signal/ConversationView/ConversationHeaderView.swift index 26d8c92466..fe3a31ebc4 100644 --- a/Signal/ConversationView/ConversationHeaderView.swift +++ b/Signal/ConversationView/ConversationHeaderView.swift @@ -3,136 +3,117 @@ // SPDX-License-Identifier: AGPL-3.0-only // -import Foundation -public import SignalUI -import UIKit +import SignalUI -public protocol ConversationHeaderViewDelegate: AnyObject { +protocol ConversationHeaderViewDelegate: AnyObject { func didTapConversationHeaderView(_ conversationHeaderView: ConversationHeaderView) func didTapConversationHeaderViewAvatar(_ conversationHeaderView: ConversationHeaderView) } -public class ConversationHeaderView: UIView { +class ConversationHeaderView: UIView { - public weak var delegate: ConversationHeaderViewDelegate? + weak var delegate: ConversationHeaderViewDelegate? - public var attributedTitle: NSAttributedString? { + var titleIcon: UIImage? { get { - return self.titleLabel.attributedText + return titleIconView.image } set { - self.titleLabel.attributedText = newValue + titleIconView.image = newValue + titleIconView.isHidden = newValue == nil } } - public var titleIcon: UIImage? { - get { - return self.titleIconView.image - } - set { - self.titleIconView.image = newValue - self.titleIconView.isHidden = newValue == nil - } - } + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = UIColor.Signal.label + label.lineBreakMode = .byTruncatingTail + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.setContentHuggingHigh() + return label + }() - public var titleIconSize: CGFloat { - get { - return self.titleIconConstraints.first?.constant ?? 0 - } - set { - self.titleIconConstraints.forEach { $0.constant = newValue } - } - } + let subtitleLabel: UILabel = { + let label = UILabel() + label.textColor = .Signal.label + label.lineBreakMode = .byTruncatingTail + label.font = .systemFont(ofSize: 13, weight: .medium) + label.setContentHuggingHigh() + return label + }() - public var attributedSubtitle: NSAttributedString? { - get { - return self.subtitleLabel.attributedText - } - set { - self.subtitleLabel.attributedText = newValue - self.subtitleLabel.isHidden = newValue == nil - } - } - - public let titlePrimaryFont = UIFont.semiboldFont(ofSize: 17) - public let subtitleFont = UIFont.regularFont(ofSize: 13).medium() - - private let titleLabel: UILabel - private let titleIconView: UIImageView - private let titleIconConstraints: [NSLayoutConstraint] - private let subtitleLabel: UILabel + private let titleIconView: UIImageView = { + let titleIconView = UIImageView() + titleIconView.isHidden = true + titleIconView.contentMode = .scaleAspectFit + titleIconView.setCompressionResistanceHigh() + return titleIconView + }() + private var titleIconSizeConstraint: NSLayoutConstraint! private var avatarSizeClass: ConversationAvatarView.Configuration.SizeClass { - traitCollection.verticalSizeClass == .compact ? .twentyFour : .thirtySix + // One size for the navigation bar on iOS 26. + guard #unavailable(iOS 26) else { return .thirtySix } + + return traitCollection.verticalSizeClass == .compact && !UIDevice.current.isPlusSizePhone + ? .twentyFour + : .thirtySix } private(set) lazy var avatarView = ConversationAvatarView( sizeClass: avatarSizeClass, localUserDisplayMode: .noteToSelf) - public init() { - titleLabel = UILabel() - titleLabel.textColor = UIColor.Signal.label - titleLabel.lineBreakMode = .byTruncatingTail - titleLabel.font = titlePrimaryFont - titleLabel.setContentHuggingHigh() + override init(frame: CGRect) { + super.init(frame: frame) - titleIconView = UIImageView() - titleIconView.contentMode = .scaleAspectFit - titleIconView.setCompressionResistanceHigh() - titleIconConstraints = titleIconView.autoSetDimensions(to: .square(20)) + translatesAutoresizingMaskIntoConstraints = false let titleColumns = UIStackView(arrangedSubviews: [titleLabel, titleIconView]) titleColumns.spacing = 5 + titleColumns.translatesAutoresizingMaskIntoConstraints = false // There is a strange bug where an initial height of 0 // breaks the layout, so set an initial height. - titleColumns.autoSetDimension( - .height, - toSize: titleLabel.font.lineHeight, - relation: .greaterThanOrEqual - ) - - subtitleLabel = UILabel() - subtitleLabel.textColor = UIColor.Signal.label - subtitleLabel.lineBreakMode = .byTruncatingTail - subtitleLabel.font = subtitleFont - subtitleLabel.setContentHuggingHigh() + titleColumns.heightAnchor.constraint(greaterThanOrEqualToConstant: titleLabel.font.lineHeight.rounded(.up)).isActive = true let textRows = UIStackView(arrangedSubviews: [titleColumns, subtitleLabel]) textRows.axis = .vertical textRows.alignment = .leading textRows.distribution = .fillProportionally - textRows.spacing = 0 - - textRows.layoutMargins = UIEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 0) + textRows.directionalLayoutMargins = .init(top: 0, leading: 8, bottom: 0, trailing: 0) textRows.isLayoutMarginsRelativeArrangement = true - // low content hugging so that the text rows push container to the right bar button item(s) - textRows.setContentHuggingLow() - - super.init(frame: .zero) - - let rootStack = UIStackView() - rootStack.layoutMargins = UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0) + let rootStack = UIStackView(arrangedSubviews: [ avatarView, textRows ]) + rootStack.directionalLayoutMargins = .init(hMargin: 0, vMargin: 4) rootStack.isLayoutMarginsRelativeArrangement = true - rootStack.axis = .horizontal rootStack.alignment = .center - rootStack.spacing = 0 - rootStack.addArrangedSubview(avatarView) - rootStack.addArrangedSubview(textRows) addSubview(rootStack) - rootStack.autoPinEdgesToSuperviewEdges() + rootStack.translatesAutoresizingMaskIntoConstraints = false + titleIconView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + titleIconView.heightAnchor.constraint(equalToConstant: 16), + titleIconView.widthAnchor.constraint(equalTo: titleIconView.heightAnchor), + + rootStack.topAnchor.constraint(equalTo: topAnchor), + rootStack.leadingAnchor.constraint(equalTo: leadingAnchor), + rootStack.trailingAnchor.constraint(equalTo: trailingAnchor), + rootStack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + if #available(iOS 26, *) { + heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + } let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapView)) rootStack.addGestureRecognizer(tapGesture) } - required public init(coder: NSCoder) { + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - public func configure(threadViewModel: ThreadViewModel) { + func configure(threadViewModel: ThreadViewModel) { avatarView.updateWithSneakyTransactionIfNecessary { config in config.dataSource = .thread(threadViewModel.threadRecord) config.storyConfiguration = .autoUpdate() @@ -140,13 +121,18 @@ public class ConversationHeaderView: UIView { } } - public override var intrinsicContentSize: CGSize { + override var intrinsicContentSize: CGSize { // Grow to fill as much of the navbar as possible. - return UIView.layoutFittingExpandedSize + return .init(width: .greatestFiniteMagnitude, height: UIView.noIntrinsicMetric) } - public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) + + // One size for the navigation bar on iOS 26. + guard #unavailable(iOS 26) else { return } + + guard traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass else { return } avatarView.updateWithSneakyTransactionIfNecessary { config in config.sizeClass = avatarSizeClass } diff --git a/Signal/ConversationView/ConversationViewController+Delegates.swift b/Signal/ConversationView/ConversationViewController+Delegates.swift index 0ed89bcf90..5b25d70d7f 100644 --- a/Signal/ConversationView/ConversationViewController+Delegates.swift +++ b/Signal/ConversationView/ConversationViewController+Delegates.swift @@ -191,13 +191,13 @@ extension ConversationViewController: ContactShareViewControllerDelegate { // MARK: - extension ConversationViewController: ConversationHeaderViewDelegate { - public func didTapConversationHeaderView(_ conversationHeaderView: ConversationHeaderView) { + func didTapConversationHeaderView(_ conversationHeaderView: ConversationHeaderView) { AssertIsOnMainThread() showConversationSettings() } - public func didTapConversationHeaderViewAvatar(_ conversationHeaderView: ConversationHeaderView) { + func didTapConversationHeaderViewAvatar(_ conversationHeaderView: ConversationHeaderView) { AssertIsOnMainThread() if conversationHeaderView.avatarView.configuration.hasStoriesToDisplay { diff --git a/Signal/ConversationView/ConversationViewController+UI.swift b/Signal/ConversationView/ConversationViewController+UI.swift index eba4753c5a..ea40b29f60 100644 --- a/Signal/ConversationView/ConversationViewController+UI.swift +++ b/Signal/ConversationView/ConversationViewController+UI.swift @@ -8,36 +8,28 @@ import SignalServiceKit public import SignalUI extension ConversationViewController { + public func updateNavigationTitle() { AssertIsOnMainThread() - self.title = nil + let title = threadViewModel.name - if thread.isNoteToSelf { - headerView.titleIcon = Theme.iconImage(.official) - headerView.titleIconSize = 16 - } else { - headerView.titleIcon = nil - } + // Important as it will be displayed in =6.2) - if #available(iOS 26.0, *) { + + if #available(iOS 26, *) { videoCallButton.tintColor = UIColor.Signal.green videoCallButton.style = .prominent } -#endif } else { videoCallButton.image = Theme.iconImage(.buttonVideoCall) videoCallButton.target = self @@ -146,13 +138,13 @@ extension ConversationViewController { videoCallButton.isEnabled = ( AppEnvironment.shared.callService.callServiceState.currentCall == nil - || self.isCurrentCallForThread + || isCurrentCallForThread ) videoCallButton.accessibilityLabel = OWSLocalizedString( "VIDEO_CALL_LABEL", comment: "Accessibility label for placing a video call" ) - self.groupCallBarButtonItem = videoCallButton + groupCallBarButtonItem = videoCallButton barButtons.append(videoCallButton) } else { let audioCallButton = UIBarButtonItem( @@ -191,24 +183,16 @@ extension ConversationViewController { public func updateNavigationBarSubtitleLabel() { AssertIsOnMainThread() - let hasCompactHeader = self.traitCollection.verticalSizeClass == .compact - if hasCompactHeader { - self.headerView.attributedSubtitle = nil + // Shorter, more vertically compact navigation bar doesn't have second line of text. + if #unavailable(iOS 26), !UIDevice.current.isPlusSizePhone, traitCollection.verticalSizeClass == .compact { + headerView.subtitleLabel.text = nil return } let subtitleText = NSMutableAttributedString() - let subtitleFont = self.headerView.subtitleFont - // Use higher-contrast color for the blurred iOS 26 nav bars - let fontColor: UIColor = if #available(iOS 26, *), BuildFlags.iOS26SDKIsAvailable { - UIColor.Signal.label - } else { - Theme.navbarTitleColor.withAlphaComponent(0.9) - } - let attributes: [NSAttributedString.Key: Any] = [ - .font: subtitleFont, - .foregroundColor: fontColor, - ] + let subtitleFont = headerView.subtitleLabel.font! + // To ensure a single source of text color do not set `color` attributes unless you really need to. + let attributes: [NSAttributedString.Key: Any] = [ .font: subtitleFont ] let hairSpace = "\u{200a}" let thinSpace = "\u{2009}" let iconSpacer = UIDevice.current.isNarrowerThanIPhone6 ? hairSpace : thinSpace @@ -235,11 +219,13 @@ extension ConversationViewController { subtitleText.appendTemplatedImage(named: Theme.iconName(.timer16), font: subtitleFont) subtitleText.append(iconSpacer, attributes: attributes) - subtitleText.append(DateUtil.formatDuration( - seconds: disappearingMessagesConfiguration.durationSeconds, - useShortFormat: true - ), - attributes: attributes) + subtitleText.append( + DateUtil.formatDuration( + seconds: disappearingMessagesConfiguration.durationSeconds, + useShortFormat: true + ), + attributes: attributes + ) } if isVerified { @@ -256,7 +242,7 @@ extension ConversationViewController { ) } - headerView.attributedSubtitle = subtitleText + headerView.subtitleLabel.attributedText = subtitleText } public var safeContentHeight: CGFloat {