Fixes/improvements for ConversationHeaderView.

• 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.
This commit is contained in:
Igor Solomennikov
2025-12-02 16:11:13 -08:00
committed by GitHub
parent 851636ac18
commit 564bf78f5a
4 changed files with 113 additions and 141 deletions

View File

@@ -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?

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 <Back button popup in view controllers
// pushed over ConversationViewController.
navigationItem.title = title
let attributedName = NSMutableAttributedString(
string: threadViewModel.name,
attributes: [
.foregroundColor: UIColor.Signal.label,
]
)
headerView.titleIcon = thread.isNoteToSelf ? Theme.iconImage(.official) : nil
if conversationViewModel.isSystemContact {
// To ensure a single source of text color do not set `color` attributes unless you really need to.
let contactIcon = SignalSymbol.personCircle.attributedString(
dynamicTypeBaseSize: 14,
weight: .bold,
leadingCharacter: .space
)
attributedName.append(contactIcon)
}
if headerView.attributedTitle != attributedName {
headerView.attributedTitle = attributedName
headerView.titleLabel.attributedText = NSAttributedString(string: title).stringByAppendingString(contactIcon)
} else {
headerView.titleLabel.text = title
}
}
@@ -80,10 +72,9 @@ extension ConversationViewController {
public func updateBarButtonItems() {
AssertIsOnMainThread()
if #available(iOS 26, *), BuildFlags.iOS26SDKIsAvailable {
// iOS 26 already doesn't show back button text
} else {
if #unavailable(iOS 26) {
// Don't include "Back" text on view controllers pushed above us, just use the arrow.
// iOS 26 already doesn't show back button text
navigationItem.backBarButtonItem = UIBarButtonItem(
title: "",
style: .plain,
@@ -94,29 +85,31 @@ extension ConversationViewController {
navigationItem.hidesBackButton = false
navigationItem.leftBarButtonItem = nil
self.groupCallBarButtonItem = nil
groupCallBarButtonItem = nil
switch uiMode {
case .search:
if self.userLeftGroup {
if userLeftGroup {
navigationItem.rightBarButtonItems = []
return
}
owsAssertDebug(navigationItem.searchController != nil)
return
case .selection:
navigationItem.rightBarButtonItems = [ self.cancelSelectionBarButtonItem ]
navigationItem.leftBarButtonItem = self.deleteAllBarButtonItem
navigationItem.rightBarButtonItems = [ cancelSelectionBarButtonItem ]
navigationItem.leftBarButtonItem = deleteAllBarButtonItem
navigationItem.hidesBackButton = true
return
case .normal:
if self.userLeftGroup {
if userLeftGroup {
navigationItem.rightBarButtonItems = []
return
}
var barButtons = [UIBarButtonItem]()
if self.canCall {
if self.isGroupConversation {
if canCall {
if isGroupConversation {
let videoCallButton = UIBarButtonItem()
if conversationViewModel.groupCallInProgress {
@@ -132,12 +125,11 @@ extension ConversationViewController {
)
pill.buttonText = self.isCurrentCallForThread ? returnString : CallStrings.joinCallPillButtonTitle
videoCallButton.customView = pill
#if compiler(>=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 {