fix(ios): let channel titles use available navigation space (#8137)

Channel titles could truncate while space remained between the
navigation buttons. The title was sized before inheriting its displayed
fonts, and longer names also hit a fixed 240-point cap.

Use Auto Layout and stack views for the title, subtitle, avatar, and
presence indicator. UIKit now updates the layout when fonts change
without resetting the title frame. The navigation bar constrains the
bubble between its buttons; short names remain compact and long names
use the available space before truncating.

[Before/after simulator
captures](https://github.com/block/buzz/pull/8137#issuecomment-6041101824)

### Validation

- All 97 native iOS tests passed on an iPhone 17 Pro running iOS 26.5.
Coverage includes inherited text size, increasing and decreasing text
size, 320/393/600-point widths, right-to-left avatar placement, presence
indicators, and title accessibility/taps.
- Exercised the production native header in the simulator and captured
the full channel name with its member count after the refactor. No
relay/community connection was needed.
- [CI passed for the Auto Layout
refactor](https://github.com/block/buzz/actions/runs/37644234496),
including mobile tests, Android debug, iOS simulator, native pagination
accessibility, and unsigned iOS release builds. Mobile push checks also
passed.
- The earlier broad local gate hit ACP deadline and desktop subprocess
timing failures; all failed tests passed on retry, including the
complete serialized ACP suite. Remaining checks and builds passed.
- Agent review completed. Human app-test confirmation is not applicable
under the author's standing Buzz mobile exception.

### Post-merge validation

During the mobile release baking period, exercise long channel names at
default and larger text sizes on narrow and wide screens; verify Back,
Huddle, and title taps remain accessible and do not overlap.

buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-07 09:24:39 -07:00
committed by GitHub
parent da651ad672
commit c96d707f6e
2 changed files with 212 additions and 69 deletions

View File

@@ -23,15 +23,12 @@ final class IosNavigationBarFactory: NSObject, FlutterPlatformViewFactory {
}
final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate {
var maximumWidth: CGFloat = 240 {
didSet { if maximumWidth != oldValue { invalidateIntrinsicContentSize() } }
}
var onActivate: (() -> Void)?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private var avatarView: UIImageView?
private var presenceView: UIView?
private var subtitlePresenceView: UIView?
private let contentStack = UIStackView()
private let textStack = UIStackView()
private let subtitleStack = UIStackView()
init(title: String?, subtitle: String, color: UIColor) {
if #available(iOS 26.0, *) {
@@ -42,17 +39,36 @@ final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate
clipsToBounds = true
layer.cornerCurve = .continuous
titleLabel.text = title
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.textColor = color
subtitleLabel.text = subtitle
subtitleLabel.font = .preferredFont(forTextStyle: .caption1)
subtitleLabel.textColor = .secondaryLabel
for label in [titleLabel, subtitleLabel] {
label.textAlignment = .center
label.lineBreakMode = .byTruncatingTail
label.adjustsFontForContentSizeCategory = false
contentView.addSubview(label)
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
subtitleStack.axis = .horizontal
subtitleStack.alignment = .center
subtitleStack.spacing = 6
subtitleStack.addArrangedSubview(subtitleLabel)
textStack.axis = .vertical
textStack.alignment = .center
textStack.addArrangedSubview(titleLabel)
textStack.addArrangedSubview(subtitleStack)
contentStack.axis = .horizontal
contentStack.alignment = .center
contentStack.spacing = 8
contentStack.addArrangedSubview(textStack)
contentStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(contentStack)
NSLayoutConstraint.activate([
contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
contentStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
])
translatesAutoresizingMaskIntoConstraints = false
updateFonts()
// Flutter creates platform views with a zero frame. Keep a nonzero
// intrinsic width and let UINavigationBar compress it between its items.
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
@@ -64,11 +80,23 @@ final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate
}
func setAvatar(_ image: UIImage?, presence: UIColor?) {
let avatar = UIImageView(image: image)
// Bar-item images carry alignment insets; the title owns its 32pt layout.
let avatar = UIImageView(image: image?.withAlignmentRectInsets(.zero))
avatar.accessibilityIdentifier = "dm-navigation-avatar"
avatar.contentMode = .scaleAspectFit
contentView.addSubview(avatar)
avatarView = avatar
let avatarContainer = UIView()
avatar.translatesAutoresizingMaskIntoConstraints = false
avatarContainer.addSubview(avatar)
contentStack.insertArrangedSubview(avatarContainer, at: 0)
NSLayoutConstraint.activate([
avatarContainer.widthAnchor.constraint(equalToConstant: 32),
avatarContainer.heightAnchor.constraint(equalToConstant: 32),
avatar.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
avatar.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
avatar.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
avatar.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
])
textStack.alignment = .leading
for label in [titleLabel, subtitleLabel] { label.textAlignment = .natural }
if let presence {
let badge = UIView()
@@ -77,9 +105,16 @@ final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate
badge.layer.borderWidth = 1.5
badge.layer.borderColor = UIColor.systemBackground.cgColor
badge.accessibilityIdentifier = "dm-navigation-presence"
contentView.addSubview(badge)
presenceView = badge
badge.translatesAutoresizingMaskIntoConstraints = false
avatarContainer.addSubview(badge)
NSLayoutConstraint.activate([
badge.widthAnchor.constraint(equalToConstant: 8),
badge.heightAnchor.constraint(equalToConstant: 8),
badge.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
badge.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
])
}
invalidateIntrinsicContentSize()
}
func setSubtitlePresence(_ color: UIColor) {
@@ -88,8 +123,11 @@ final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate
dot.layer.cornerRadius = 3
dot.isAccessibilityElement = false
dot.accessibilityIdentifier = "dm-navigation-status-dot"
contentView.addSubview(dot)
subtitlePresenceView = dot
subtitleStack.insertArrangedSubview(dot, at: 0)
NSLayoutConstraint.activate([
dot.widthAnchor.constraint(equalToConstant: 6),
dot.heightAnchor.constraint(equalToConstant: 6),
])
invalidateIntrinsicContentSize()
}
@@ -125,13 +163,25 @@ final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate
}
override var intrinsicContentSize: CGSize {
CGSize(width: min(maximumWidth, max(titleLabel.intrinsicContentSize.width, subtitleLabel.intrinsicContentSize.width + (subtitlePresenceView == nil ? 0 : 12)) + 24 + (avatarView == nil ? 0 : 40)),
height: max(44, titleLabel.intrinsicContentSize.height + subtitleLabel.intrinsicContentSize.height))
let contentSize = contentStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
return CGSize(width: contentSize.width + 24, height: max(44, contentSize.height))
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height / 2
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
updateFonts()
invalidateIntrinsicContentSize()
setNeedsLayout()
}
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let desired = intrinsicContentSize
return CGSize(width: min(size.width, desired.width), height: desired.height)
}
private func updateFonts() {
// Compact navigation remains a 44pt toolbar. Scale within that budget;
// the complete title/subtitle remains available as one VoiceOver label.
titleLabel.font = UIFontMetrics(forTextStyle: .headline).scaledFont(
@@ -140,26 +190,11 @@ final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate
subtitleLabel.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(
for: .systemFont(ofSize: 12), maximumPointSize: 14,
compatibleWith: traitCollection)
let titleHeight = titleLabel.intrinsicContentSize.height
let subtitleHeight = subtitleLabel.intrinsicContentSize.height
let top = (bounds.height - titleHeight - subtitleHeight) / 2
let hasAvatar = avatarView != nil
let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
let textX: CGFloat = hasAvatar && !rtl ? 52 : 12
let textWidth = max(0, bounds.width - 24 - (hasAvatar ? 40 : 0))
titleLabel.frame = CGRect(x: textX, y: top, width: textWidth, height: titleHeight)
subtitleLabel.frame = CGRect(x: textX, y: top + titleHeight, width: textWidth, height: subtitleHeight)
if let dot = subtitlePresenceView {
let labelWidth = min(subtitleLabel.intrinsicContentSize.width, max(0, textWidth - 12))
let groupX = textX + (textWidth - labelWidth - 12) / 2
dot.frame = CGRect(x: rtl ? groupX + labelWidth + 6 : groupX,
y: top + titleHeight + (subtitleHeight - 6) / 2, width: 6, height: 6)
subtitleLabel.frame = CGRect(x: rtl ? groupX : groupX + 12,
y: top + titleHeight, width: labelWidth, height: subtitleHeight)
}
let avatarX: CGFloat = rtl ? bounds.width - 44 : 12
avatarView?.frame = CGRect(x: avatarX, y: (bounds.height - 32) / 2, width: 32, height: 32)
presenceView?.frame = CGRect(x: avatarX + (rtl ? 0 : 24), y: (bounds.height - 32) / 2 + 24, width: 8, height: 8)
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height / 2
}
}

View File

@@ -8,6 +8,11 @@ import XCTest
class RunnerTests: XCTestCase {
@MainActor
private func descendants(of view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + descendants(of: $0) }
}
@MainActor
func testNativeRemovalConfirmationUsesDestructiveAlertAndCancelsOnce() async throws {
let messenger = NavigationTestMessenger()
@@ -118,20 +123,80 @@ class RunnerTests: XCTestCase {
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
XCTAssertGreaterThan(title.frame.width, 0)
XCTAssertGreaterThan(title.intrinsicContentSize.width, 0)
XCTAssertLessThanOrEqual(title.intrinsicContentSize.width, 240)
_ = bar.view()
}
@MainActor
func testLongGroupTitleIsCappedAndTruncates() {
func testLongGroupTitleFitsAvailableWidthAndTruncates() {
let title = NavigationTitleView(title: String(repeating: "Long participant name, ", count: 12), subtitle: "12 members", color: .label)
title.maximumWidth = 180
XCTAssertEqual(title.intrinsicContentSize.width, 180)
title.frame = CGRect(origin: .zero, size: title.intrinsicContentSize)
XCTAssertGreaterThan(title.intrinsicContentSize.width, 240)
let fitted = title.sizeThatFits(CGSize(width: 180, height: 44))
XCTAssertEqual(fitted.width, 180)
title.frame = CGRect(origin: .zero, size: fitted)
title.layoutIfNeeded()
for label in title.contentView.subviews.compactMap({ $0 as? UILabel }) {
for label in descendants(of: title.contentView).compactMap({ $0 as? UILabel }) {
XCTAssertEqual(label.lineBreakMode, .byTruncatingTail)
XCTAssertTrue(title.bounds.contains(label.frame))
XCTAssertTrue(title.bounds.contains(label.convert(label.bounds, to: title)))
}
}
@MainActor
func testConversationTitleUsesAvailableSpaceAfterInheritingTextSize() throws {
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
let messenger = NavigationTestMessenger()
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
var arguments: [String: Any] = ["title": "buzz-onboarding", "subtitle": "27 members",
"back": true, "titleEnabled": true,
"actions": [["id": "huddle", "label": "Huddle", "symbol": "headphones", "enabled": true]]]
let bar = factory.create(withFrame: CGRect(x: 0, y: 0, width: 393, height: 160),
viewIdentifier: 999991, arguments: arguments)
parent.view.addSubview(bar.view())
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
parent.setOverrideTraitCollection(UITraitCollection(preferredContentSizeCategory: .extraExtraExtraLarge),
forChild: navigation)
parent.view.layoutIfNeeded()
bar.view().layoutIfNeeded()
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
let label = try XCTUnwrap(descendants(of: title.contentView).compactMap { $0 as? UILabel }.first)
XCTAssertEqual(navigation.traitCollection.preferredContentSizeCategory, .extraExtraExtraLarge)
XCTAssertEqual(label.font.pointSize, 20, accuracy: 0.01)
XCTAssertGreaterThanOrEqual(label.bounds.width, label.intrinsicContentSize.width)
parent.setOverrideTraitCollection(UITraitCollection(preferredContentSizeCategory: .large),
forChild: navigation)
parent.view.layoutIfNeeded()
bar.view().layoutIfNeeded()
XCTAssertEqual(label.font.pointSize, 17, accuracy: 0.01)
XCTAssertGreaterThanOrEqual(label.bounds.width, label.intrinsicContentSize.width)
XCTAssertLessThanOrEqual(title.bounds.width, title.intrinsicContentSize.width + 1)
arguments["title"] = String(repeating: "Long channel name ", count: 12)
messenger.configure(arguments)
for width in [320.0, 393.0, 600.0] {
bar.view().frame.size.width = width
bar.view().setNeedsLayout()
bar.view().layoutIfNeeded()
let longTitle = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
let titleFrame = longTitle.convert(longTitle.bounds, to: navigation.navigationBar)
func controls(_ view: UIView) -> [UIView] {
(view is UIControl ? [view] : []) + view.subviews.flatMap(controls)
}
let buttons = controls(navigation.navigationBar)
let back = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Back" })
let huddle = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Huddle" })
let backFrame = back.convert(back.bounds, to: navigation.navigationBar)
let huddleFrame = huddle.convert(huddle.bounds, to: navigation.navigationBar)
XCTAssertGreaterThan(titleFrame.minX, backFrame.maxX)
XCTAssertLessThan(titleFrame.maxX, huddleFrame.minX)
// A long name uses the available slot, leaving only UIKit's button gaps.
XCTAssertLessThan(titleFrame.minX - backFrame.maxX, 24)
XCTAssertLessThan(huddleFrame.minX - titleFrame.maxX, 24)
if width >= 393 { XCTAssertGreaterThan(titleFrame.width, 240) }
}
}
@@ -141,11 +206,47 @@ class RunnerTests: XCTestCase {
title.setSubtitlePresence(.gray)
title.frame = CGRect(x: 0, y: 0, width: 180, height: 44)
title.layoutIfNeeded()
let dot = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-status-dot" })
let label = try XCTUnwrap(title.contentView.subviews.compactMap { $0 as? UILabel }.first { $0.text == "Offline" })
let dot = try XCTUnwrap(descendants(of: title.contentView).first { $0.accessibilityIdentifier == "dm-navigation-status-dot" })
let label = try XCTUnwrap(descendants(of: title.contentView).compactMap { $0 as? UILabel }.first { $0.text == "Offline" })
XCTAssertEqual(label.frame.minX - dot.frame.maxX, 6, accuracy: 0.1)
XCTAssertEqual(label.frame.midY, dot.frame.midY, accuracy: 0.1)
XCTAssertEqual((dot.frame.minX + label.frame.maxX) / 2, title.bounds.midX, accuracy: 0.1)
// Auto Layout aligns centers to the display pixel grid.
XCTAssertEqual(label.frame.midY, dot.frame.midY, accuracy: 1 / title.traitCollection.displayScale)
XCTAssertEqual((dot.convert(dot.bounds, to: title).minX + label.convert(label.bounds, to: title).maxX) / 2, title.bounds.midX, accuracy: 0.1)
}
@MainActor
func testTitleStacksMirrorAvatarAndPresenceInRTL() throws {
let parent = UIViewController()
let window = UIWindow(windowScene: try XCTUnwrap(
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first))
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
let factory = IosNavigationBarFactory(messenger: NavigationTestMessenger(), parent: parent)
let bar = factory.create(withFrame: CGRect(x: 0, y: 0, width: 393, height: 160),
viewIdentifier: 999994, arguments: ["title": "Alice", "subtitle": "Online",
"titleAvatar": ["avatarInitial": "A"], "titlePresenceColor": 0xFF00FF00])
parent.view.addSubview(bar.view())
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
parent.setOverrideTraitCollection(UITraitCollection(layoutDirection: .rightToLeft), forChild: navigation)
parent.view.layoutIfNeeded()
bar.view().layoutIfNeeded()
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
XCTAssertEqual(title.effectiveUserInterfaceLayoutDirection, .rightToLeft)
title.layoutIfNeeded()
let avatar = try XCTUnwrap(descendants(of: title.contentView).first {
$0.accessibilityIdentifier == "dm-navigation-avatar"
})
let badge = try XCTUnwrap(descendants(of: title.contentView).first {
$0.accessibilityIdentifier == "dm-navigation-presence"
})
let label = try XCTUnwrap(descendants(of: title.contentView).compactMap { $0 as? UILabel }.first)
let avatarFrame = avatar.convert(avatar.bounds, to: title)
let labelFrame = label.convert(label.bounds, to: title)
let badgeFrame = badge.convert(badge.bounds, to: title)
XCTAssertGreaterThanOrEqual(avatarFrame.minX - labelFrame.maxX, 8 - 0.5)
XCTAssertEqual(badgeFrame.minX, avatarFrame.minX, accuracy: 0.5)
XCTAssertEqual(avatarFrame.size, CGSize(width: 32, height: 32))
}
@MainActor
@@ -154,11 +255,11 @@ class RunnerTests: XCTestCase {
let title = NavigationTitleView(title: "general", subtitle: "36 members", color: .label)
wrapper.addSubview(title)
let label = UILabel()
title.addSubview(label)
title.contentView.addSubview(label)
XCTAssertTrue(title.acceptsTitleTouch(in: label))
XCTAssertTrue(title.acceptsTitleTouch(in: title))
let disclosure = UIButton(type: .custom)
title.addSubview(disclosure)
title.contentView.addSubview(disclosure)
XCTAssertFalse(title.acceptsTitleTouch(in: disclosure))
var activated = false
title.onActivate = { activated = true }
@@ -192,8 +293,8 @@ class RunnerTests: XCTestCase {
title.layoutIfNeeded()
XCTAssertEqual(title.traitCollection.preferredContentSizeCategory, .accessibilityExtraExtraExtraLarge)
XCTAssertTrue(title.accessibilityLabel?.contains(subtitle) == true)
for label in title.contentView.subviews.compactMap({ $0 as? UILabel }) where !label.isHidden {
XCTAssertTrue(title.bounds.contains(label.frame))
for label in descendants(of: title.contentView).compactMap({ $0 as? UILabel }) where !label.isHidden {
XCTAssertTrue(title.bounds.contains(label.convert(label.bounds, to: title)))
XCTAssertGreaterThanOrEqual(label.bounds.height, label.intrinsicContentSize.height)
let frame = label.convert(label.bounds, to: navigation.navigationBar)
XCTAssertGreaterThanOrEqual(frame.minY, 0)
@@ -246,13 +347,13 @@ class RunnerTests: XCTestCase {
for direction in [UISemanticContentAttribute.forceLeftToRight, .forceRightToLeft] {
title.semanticContentAttribute = direction
title.layoutIfNeeded()
let status = try XCTUnwrap(title.contentView.subviews.compactMap { $0 as? UILabel }.first { $0.text == subtitle })
let status = try XCTUnwrap(descendants(of: title.contentView).compactMap { $0 as? UILabel }.first { $0.text == subtitle })
XCTAssertFalse(status.isHidden)
XCTAssertGreaterThan(status.bounds.width, 0)
XCTAssertTrue(title.bounds.contains(status.frame))
XCTAssertTrue(title.bounds.contains(status.convert(status.bounds, to: title)))
XCTAssertTrue(title.isAccessibilityElement)
XCTAssertTrue(title.accessibilityLabel?.contains(explanation) == true)
XCTAssertFalse(title.contentView.subviews.contains { $0 is UIButton }, "Retention stays text-only")
XCTAssertFalse(descendants(of: title.contentView).contains { $0 is UIButton }, "Retention stays text-only")
}
XCTAssertTrue(title.accessibilityActivate())
XCTAssertEqual(messenger.actions.last, "title")
@@ -262,7 +363,7 @@ class RunnerTests: XCTestCase {
messenger.configure(args)
let permanent = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
XCTAssertFalse(permanent.accessibilityLabel?.contains("Ephemeral") == true)
XCTAssertFalse(permanent.contentView.subviews.compactMap { $0 as? UILabel }.contains { $0.text?.contains("Temporary") == true })
XCTAssertFalse(descendants(of: permanent.contentView).compactMap { $0 as? UILabel }.contains { $0.text?.contains("Temporary") == true })
_ = bar // Keep the platform view alive throughout reconfiguration.
}
}
@@ -271,6 +372,11 @@ class RunnerTests: XCTestCase {
func testNativeDmTitlePreservesAvatarPresenceAndAccessibility() throws {
let messenger = NavigationTestMessenger()
let parent = UIViewController()
let window = UIWindow(windowScene: try XCTUnwrap(
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first))
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
let bar = factory.create(
withFrame: CGRect(x: 0, y: 0, width: 390, height: 120), viewIdentifier: 999996,
@@ -282,21 +388,22 @@ class RunnerTests: XCTestCase {
parent.view.addSubview(bar.view())
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
title.frame.size = title.intrinsicContentSize
parent.view.layoutIfNeeded()
bar.view().layoutIfNeeded()
title.layoutIfNeeded()
XCTAssertEqual(title.accessibilityLabel, "Alice, Online")
XCTAssertEqual(title.accessibilityTraits, .header)
XCTAssertFalse(title.accessibilityActivate())
let avatar = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-avatar" } as? UIImageView)
let avatar = try XCTUnwrap(descendants(of: title.contentView).first { $0.accessibilityIdentifier == "dm-navigation-avatar" } as? UIImageView)
XCTAssertNotNil(avatar.image)
XCTAssertEqual(avatar.frame.size, CGSize(width: 32, height: 32))
let presence = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-presence" })
let presence = try XCTUnwrap(descendants(of: title.contentView).first { $0.accessibilityIdentifier == "dm-navigation-presence" })
XCTAssertEqual(presence.backgroundColor, UIColor.green)
let labels = title.contentView.subviews.compactMap { $0 as? UILabel }
let labels = descendants(of: title.contentView).compactMap { $0 as? UILabel }
XCTAssertEqual(labels.map(\.text), ["Alice", "Online"])
for label in labels {
XCTAssertTrue(title.bounds.contains(label.frame))
XCTAssertFalse(label.frame.intersects(avatar.frame))
XCTAssertTrue(title.bounds.contains(label.convert(label.bounds, to: title)))
XCTAssertFalse(label.convert(label.bounds, to: title).intersects(avatar.convert(avatar.bounds, to: title)))
}
}
@@ -323,10 +430,10 @@ class RunnerTests: XCTestCase {
title.layoutIfNeeded()
// The title owns one native glass capsule and remains a single action.
XCTAssertEqual(title.accessibilityTraits, .button)
let labels = title.contentView.subviews.compactMap { $0 as? UILabel }
let labels = descendants(of: title.contentView).compactMap { $0 as? UILabel }
XCTAssertEqual(labels.map(\.text), ["general", "36 members"])
for label in labels {
XCTAssertTrue(title.bounds.contains(label.frame))
XCTAssertTrue(title.bounds.contains(label.convert(label.bounds, to: title)))
XCTAssertGreaterThanOrEqual(label.bounds.width, label.intrinsicContentSize.width)
XCTAssertGreaterThanOrEqual(label.bounds.height, label.intrinsicContentSize.height)
}
@@ -355,7 +462,7 @@ class RunnerTests: XCTestCase {
title.frame.size = title.intrinsicContentSize
title.layoutIfNeeded()
XCTAssertEqual(title.layer.cornerRadius, title.bounds.height / 2)
XCTAssertEqual(title.contentView.subviews.compactMap { $0 as? UILabel }.count, 2)
XCTAssertEqual(descendants(of: title.contentView).compactMap { $0 as? UILabel }.count, 2)
XCTAssertTrue(title.accessibilityActivate())
XCTAssertEqual(messenger.actions.last, "title")
for offset in [0.0, 6.0, 52.0, 0.0, -20.0] {
@@ -503,6 +610,7 @@ class RunnerTests: XCTestCase {
}
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowScene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }