mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user