Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/_ci-clients.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,17 @@ jobs:
run: swift build -c release --package-path mobile/ios/BuzzPushKit
- name: Test
run: swift test --package-path mobile/ios/BuzzPushKit
- name: Test native pagination geometry
run: /bin/bash scripts/test-mobile-pagination.sh
- name: Build complete iOS simulator app
run: cd mobile && flutter build ios --simulator --debug --no-pub
- name: Test native pagination accessibility
run: |
device=$(xcrun simctl create "Pagination accessibility" "iPhone 16")
trap 'xcrun simctl shutdown "$device"; xcrun simctl delete "$device"' EXIT
xcrun simctl boot "$device"
xcrun simctl bootstatus "$device" -b
/bin/bash scripts/test-mobile-pagination-accessibility.sh "$device"
- name: Build complete unsigned iOS release
run: |
/bin/bash scripts/test-mobile-gateway-recipes.sh
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Flutter
import UIKit

@main
struct ThemePaginationAccessibilityTests {
static func check(_ condition: Bool, _ message: String) {
precondition(condition, message)
}

@MainActor static func main() {
let messenger = PaginationTestMessenger()
let platform = ThemePaginationGlassControlPlatformView(
frame: CGRect(x: 0, y: 0, width: 116, height: 54),
viewIdentifier: 42,
arguments: ["accessibilityLabel": "Photo", "count": 3, "selected": 1],
messenger: messenger
)
let control = platform.view()
control.layoutIfNeeded()
func accessibleViews(_ view: UIView) -> [UIView] {
(view.isAccessibilityElement ? [view] : []) + view.subviews.flatMap(accessibleViews)
}
check(accessibleViews(control).count == 1, "Exactly one native accessibility element")
check(control.accessibilityTraits.contains(.adjustable), "Paginator must be adjustable")
check(control.accessibilityLabel == "Photo", "Native label must match content")
check(control.accessibilityValue == "2 of 3", "Initial page value")
control.accessibilityIncrement()
check(control.accessibilityValue == "3 of 3", "Increment updates value")
check(messenger.selections == [2], "Increment is delivered to Flutter once")
control.accessibilityIncrement()
check(messenger.selections == [2], "Increment stops at last page")
control.accessibilityDecrement()
control.accessibilityDecrement()
control.accessibilityDecrement()
check(control.accessibilityValue == "1 of 3", "Decrement stops at first page")
check(messenger.selections == [2, 1, 0], "Each decrement is delivered once")
check(accessibleViews(control).count == 1, "Selection keeps a single owner")
print("Native pagination accessibility passed")
}
}

private final class PaginationTestMessenger: NSObject, FlutterBinaryMessenger {
var selections: [Int] = []
func send(onChannel channel: String, message: Data?) {
guard let message else { return }
let call = FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message)
if call.method == "selected", let selected = call.arguments as? Int {
selections.append(selected)
}
}
func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) {
send(onChannel: channel, message: message)
callback?(nil)
}
func setMessageHandlerOnChannel(
_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?
) -> FlutterBinaryMessengerConnection { 1 }
func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation

@main
struct ThemePaginationGeometryTests {
static func check(_ condition: Bool, _ message: String) {
if !condition {
print("FAIL: \(message)")
exit(1)
}
}

static func main() {
let windows = [(3, 1, 0), (20, 0, 0), (20, 10, 7), (20, 19, 13)]
var checkedCenters = 0
for isRTL in [false, true] {
for (count, selected, firstPage) in windows {
// Actual dot centers inside the 92-point padded track.
let centers: [CGFloat] = count == 3 ? [34, 46, 58] : [10, 22, 34, 46, 58, 70, 82]
let geometry = ThemePaginationGeometry(count: count, selected: selected, width: 92, isRTL: isRTL)
check(geometry.windowStart == firstPage, "wrong window at \(selected)")
for (slot, center) in centers.enumerated() {
let page = firstPage + (isRTL ? centers.count - 1 - slot : slot)
check(geometry.centerX(for: page) == center, "wrong rendered center for page \(page)")
for offset: CGFloat in [-2, 0, 2] {
check(geometry.page(at: center + offset) == page,
"count=\(count), selected=\(selected), RTL=\(isRTL), tap=\(center + offset), expected=\(page)")
}
checkedCenters += 1
}
check(geometry.page(at: -100) == firstPage + (isRTL ? centers.count - 1 : 0), "left edge")
check(geometry.page(at: 200) == firstPage + (isRTL ? 0 : centers.count - 1), "right edge")
}
}
for isRTL in [false, true] {
for cancel in [false, true] {
var selected = 10
func current() -> ThemePaginationGeometry {
ThemePaginationGeometry(count: 20, selected: selected, width: 92, isRTL: isRTL)
}
var scrub = ThemePaginationScrub()
let target = current().centerX(for: 12)
scrub.begin(current())
for _ in 0..<8 {
selected = scrub.page(at: target, current: current())
check(selected == 12, "held pan cascaded in RTL=\(isRTL), cancel=\(cancel)")
check(scrub.geometry?.centerX(for: 12) == target, "rendered window moved")
}
// The UIKit ended path consumes its last coordinate before clearing;
// cancelled/failed paths clear without introducing another selection.
if !cancel { selected = scrub.page(at: target, current: current()) }
scrub.end()
check(scrub.geometry == nil, "end/cancel retained window")
let nextTarget = current().centerX(for: 14)
scrub.begin(current())
selected = scrub.page(at: nextTarget, current: current())
check(selected == 14, "next gesture reused stale geometry")
scrub.end()
check(current().page(at: current().centerX(for: 15)) == 15, "tap after scrub")
}
}
print("PASS: held scrubs, final events, cancellation and fresh gestures in LTR/RTL")
print("PASS: \(checkedCenters) rendered centers, neighboring taps and clamped edges in LTR/RTL")
}
}
4 changes: 4 additions & 0 deletions mobile/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; };
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; };
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; };
AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */; };
4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */; };
4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */; };
4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */; };
Expand Down Expand Up @@ -116,6 +117,7 @@
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = "<group>"; };
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = "<group>"; };
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGlassControl.swift; sourceTree = "<group>"; };
AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGeometry.swift; sourceTree = "<group>"; };
4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceNotePackager.swift; sourceTree = "<group>"; };
4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MP4Canonicalizer.swift; sourceTree = "<group>"; };
4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverMenu.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -283,6 +285,7 @@
4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */,
4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */,
4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */,
AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */,
4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */,
4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */,
4A71C0242F4120100A17E01 /* NativeAttachmentPopoverMenu.swift */,
Expand Down Expand Up @@ -616,6 +619,7 @@
4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */,
4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */,
4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */,
AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */,
4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */,
4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */,
4A71C0232F4120100A17E01 /* NativeAttachmentPopoverMenu.swift in Sources */,
Expand Down
2 changes: 2 additions & 0 deletions mobile/ios/Runner/JumpToLatestGlassButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,8 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
case "shutter": buttonIconName = "circle.fill"
case "sun": buttonIconName = "sun.max"
case "moon": buttonIconName = "moon"
case "more": buttonIconName = "ellipsis"
case "reply": buttonIconName = "arrowshape.turn.up.left"
case "systemAppearance": buttonIconName = "circle.lefthalf.filled"
case "colorSwatch": buttonIconName = "circle.fill"
default: buttonIconName = "chevron.backward"
Expand Down
52 changes: 52 additions & 0 deletions mobile/ios/Runner/ThemePaginationGeometry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

/// Shared geometry for native pagination drawing and pointer selection.
struct ThemePaginationGeometry {
static let dotSize: CGFloat = 6
static let pitch: CGFloat = 12
let count: Int
let selected: Int
let width: CGFloat
let isRTL: Bool

var visibleCount: Int { min(count, 7) }
var windowStart: Int { min(max(0, selected - visibleCount / 2), count - visibleCount) }
var hasEarlierDots: Bool { windowStart > 0 }
var hasLaterDots: Bool { windowStart + visibleCount < count }
private var firstCenter: CGFloat {
let trackWidth = CGFloat(visibleCount - 1) * Self.pitch + Self.dotSize
return (width - trackWidth) / 2 + Self.dotSize / 2
}

func slot(for page: Int) -> Int {
let logicalSlot = page - windowStart
return isRTL ? visibleCount - 1 - logicalSlot : logicalSlot
}

func centerX(for page: Int) -> CGFloat {
firstCenter + CGFloat(slot(for: page)) * Self.pitch
}

func page(at x: CGFloat) -> Int {
let slot = Int(((x - firstCenter) / Self.pitch).rounded())
let boundedSlot = min(max(0, slot), visibleCount - 1)
return windowStart + (isRTL ? visibleCount - 1 - boundedSlot : boundedSlot)
}
}

/// Freezes the rendered window for one continuous pan, including its final event.
struct ThemePaginationScrub {
private(set) var geometry: ThemePaginationGeometry?

mutating func begin(_ geometry: ThemePaginationGeometry) {
self.geometry = geometry
}

func page(at x: CGFloat, current: ThemePaginationGeometry) -> Int {
(geometry ?? current).page(at: x)
}

mutating func end() {
geometry = nil
}
}
75 changes: 54 additions & 21 deletions mobile/ios/Runner/ThemePaginationGlassControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ final class ThemePaginationGlassControlFactory: NSObject, FlutterPlatformViewFac
}

private final class ThemePaginationControl: UIControl {
private static let maximumVisibleDots = 7
private static let fullDotSize: CGFloat = 6
private static let fullDotSize = ThemePaginationGeometry.dotSize
private static let selectedDotSize: CGFloat = 10
private static let dotSpacing: CGFloat = 6
private let glassView: UIVisualEffectView
private let dotsContainer = UIView()
private var glassHeightConstraint: NSLayoutConstraint?
private var dots: [UIView] = []
private var totalCount = 1
private var selectedIndex = 0
private var scrub = ThemePaginationScrub()
private var activeColor = UIColor.label
private var inactiveColor = UIColor.secondaryLabel.withAlphaComponent(0.32)
var onSelectionChanged: ((Int) -> Void)?
Expand All @@ -53,6 +53,7 @@ private final class ThemePaginationControl: UIControl {

backgroundColor = .clear
isOpaque = false
isAccessibilityElement = true
accessibilityTraits = [.adjustable]
accessibilityLabel = "Theme"

Expand All @@ -67,11 +68,13 @@ private final class ThemePaginationControl: UIControl {
dotsContainer.isUserInteractionEnabled = false
glassView.contentView.addSubview(dotsContainer)

let glassHeightConstraint = glassView.heightAnchor.constraint(equalToConstant: 30)
self.glassHeightConstraint = glassHeightConstraint
NSLayoutConstraint.activate([
glassView.leadingAnchor.constraint(equalTo: leadingAnchor),
glassView.trailingAnchor.constraint(equalTo: trailingAnchor),
glassView.centerYAnchor.constraint(equalTo: centerYAnchor),
glassView.heightAnchor.constraint(equalToConstant: 30),
glassHeightConstraint,
dotsContainer.leadingAnchor.constraint(equalTo: glassView.contentView.leadingAnchor, constant: 12),
dotsContainer.trailingAnchor.constraint(equalTo: glassView.contentView.trailingAnchor, constant: -12),
dotsContainer.topAnchor.constraint(equalTo: glassView.contentView.topAnchor),
Expand Down Expand Up @@ -108,6 +111,15 @@ private final class ThemePaginationControl: UIControl {
inactiveColor = Self.color(fromARGB: value.uint32Value)
}

accessibilityLabel = arguments["accessibilityLabel"] as? String ?? accessibilityLabel
if let height = arguments["containerHeight"] as? NSNumber {
glassHeightConstraint?.constant = max(10, CGFloat(height.doubleValue))
setNeedsLayout()
}
if let isRTL = arguments["isRTL"] as? Bool {
semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight
}
if count != totalCount { scrub.end() }
totalCount = count
if count != dots.count {
rebuildDots(count: count)
Expand All @@ -126,11 +138,39 @@ private final class ThemePaginationControl: UIControl {
select(index: max(selectedIndex - 1, 0))
}

private var geometry: ThemePaginationGeometry {
ThemePaginationGeometry(
count: totalCount, selected: selectedIndex, width: dotsContainer.bounds.width,
isRTL: effectiveUserInterfaceLayoutDirection == .rightToLeft
)
}

@objc private func handleGesture(_ recognizer: UIGestureRecognizer) {
guard totalCount > 0 else { return }
let location = recognizer.location(in: glassView)
let progress = max(0, min(0.999_999, location.x / max(1, glassView.bounds.width)))
select(index: Int(progress * CGFloat(totalCount)))
guard dotsContainer.bounds.width > 0 else { return }
let location = recognizer.location(in: dotsContainer)
if recognizer is UIPanGestureRecognizer {
switch recognizer.state {
case .began:
scrub.begin(geometry)
case .changed:
guard scrub.geometry != nil else { return }
case .ended:
let page = scrub.page(at: location.x, current: geometry)
scrub.end()
select(index: page)
updateDots(animated: true)
return
case .cancelled, .failed:
scrub.end()
updateDots(animated: true)
return
default:
return
}
select(index: scrub.page(at: location.x, current: geometry))
} else if recognizer.state == .ended {
select(index: geometry.page(at: location.x))
}
}

private func select(index: Int) {
Expand Down Expand Up @@ -163,20 +203,13 @@ private final class ThemePaginationControl: UIControl {

private func updateDots(animated: Bool) {
guard !dots.isEmpty, dotsContainer.bounds.width > 0 else { return }
let visibleCount = min(totalCount, Self.maximumVisibleDots)
let maximumStart = max(0, totalCount - visibleCount)
let centerSlot = visibleCount / 2
let windowStart = min(max(0, selectedIndex - centerSlot), maximumStart)
let windowEnd = windowStart + visibleCount - 1
let hasEarlierDots = windowStart > 0
let hasLaterDots = windowEnd < totalCount - 1
let pitch = Self.fullDotSize + Self.dotSpacing
let trackWidth = CGFloat(visibleCount) * Self.fullDotSize
+ CGFloat(max(0, visibleCount - 1)) * Self.dotSpacing
let trackOrigin = (dotsContainer.bounds.width - trackWidth) / 2
let geometry = scrub.geometry ?? self.geometry
let visibleCount = geometry.visibleCount
let hasEarlierDots = geometry.isRTL ? geometry.hasLaterDots : geometry.hasEarlierDots
let hasLaterDots = geometry.isRTL ? geometry.hasEarlierDots : geometry.hasLaterDots
let changes = {
for (page, dot) in self.dots.enumerated() {
let slot = page - windowStart
let slot = geometry.slot(for: page)
let isVisible = (0..<visibleCount).contains(slot)
var diameter = Self.fullDotSize
if page == self.selectedIndex {
Expand All @@ -191,7 +224,7 @@ private final class ThemePaginationControl: UIControl {
diameter = 4
}
dot.center = CGPoint(
x: trackOrigin + Self.fullDotSize / 2 + CGFloat(slot) * pitch,
x: geometry.centerX(for: page),
y: self.dotsContainer.bounds.midY
)
dot.alpha = isVisible ? 1 : 0
Expand Down
Loading
Loading