diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 0d3ffd85ba..9942923409 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -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 } } diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index f83b58f2f7..9dc3eeb6d7 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -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 }