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
59 changes: 57 additions & 2 deletions mobile/ios/Runner/NativeEmojiPickerModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,22 @@ struct NativeEmojiSectionOffsetsKey: PreferenceKey {
}
}

/// Keeps scroll-driven category selection out of the picker view's own state.
/// The category buttons observe this object directly, so updating the rail does
/// not invalidate and rebuild the scroll container underneath an active drag.
final class NativeEmojiCategorySelection: ObservableObject {
@Published private(set) var selectedSectionID: String?

init(initialSectionID: String?) {
selectedSectionID = initialSectionID
}

func select(_ sectionID: String?) {
guard sectionID != selectedSectionID else { return }
selectedSectionID = sectionID
}
}

/// Pure selection logic: the highlighted section is the last one whose header
/// has scrolled to or above the top of the viewport. Extracted so the
/// scroll-tracking behaviour can be unit-tested without a live scroll view.
Expand All @@ -401,7 +417,8 @@ enum NativeEmojiCategoryTracker {
offsets: [String: CGFloat],
viewportTop: CGFloat,
viewportBottom: CGFloat? = nil,
contentBottom: CGFloat? = nil
contentBottom: CGFloat? = nil,
currentSelection: String? = nil
) -> String? {
// At the clamped bottom of an overflowing list, a final section shorter
// than the viewport can never scroll its header to the top, so the
Expand Down Expand Up @@ -432,6 +449,44 @@ enum NativeEmojiCategoryTracker {
break
}
}
return selected ?? order.first

let candidate = selected ?? currentSelection ?? order.first
guard
let candidate,
let currentSelection,
let candidateIndex = order.firstIndex(of: candidate),
let currentIndex = order.firstIndex(of: currentSelection),
candidateIndex < currentIndex
else {
return candidate
}

// Pinned headers can briefly report competing or incomplete positions as
// one section pushes another off the top. Once the next section is active,
// retain it through that small boundary jitter. A real upward scroll moves
// its header clearly back into the viewport and then releases the latch.
guard let currentTop = offsets[currentSelection] else {
// A LazyVStack can discard the old pinned header after a fast upward
// fling. Once an earlier header is a valid candidate, absence of the old
// header is evidence to release the latch rather than retain it forever.
return candidate
}
Comment thread
klopez4212 marked this conversation as resolved.
if currentTop <= viewportTop + 8 {
return currentSelection
}

// The final, short section is selected from the content boundary rather
// than its header. Keep that bottom selection stable until the content end
// has visibly moved away from the viewport edge.
if currentSelection == order.last {
guard let viewportBottom, let contentBottom else {
return currentSelection
}
if contentBottom <= viewportBottom + 8 {
return currentSelection
}
}

return candidate
}
}
92 changes: 56 additions & 36 deletions mobile/ios/Runner/NativeEmojiPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct NativeEmojiPickerView: View {
let onClose: () -> Void

@State private var query = ""
@State private var selectedSectionID: String?
@State private var categorySelection: NativeEmojiCategorySelection
@State private var selectedSkinTone: Int

private let columns = Array(
Expand All @@ -33,6 +33,11 @@ struct NativeEmojiPickerView: View {
self.onSelect = onSelect
self.onSkinToneChanged = onSkinToneChanged
self.onClose = onClose
_categorySelection = State(
initialValue: NativeEmojiCategorySelection(
initialSectionID: data.sections.first?.id
)
)
_selectedSkinTone = State(
initialValue: validNativeEmojiSkinTone(initialSkinTone)
)
Expand All @@ -49,9 +54,6 @@ struct NativeEmojiPickerView: View {
pickerContent
}
.background(Color(uiColor: appearance.surface))
.onAppear {
selectedSectionID = data.sections.first?.id
}
}
}

Expand Down Expand Up @@ -103,34 +105,17 @@ struct NativeEmojiPickerView: View {
private func categoryRail(_ proxy: ScrollViewProxy) -> some View {
HStack(spacing: 0) {
ForEach(data.sections) { section in
Button {
selectedSectionID = section.id
withAnimation(.easeOut(duration: 0.24)) {
proxy.scrollTo("section-\(section.id)", anchor: .top)
}
} label: {
Image(systemName: section.systemImage)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(
Color(
uiColor: selectedSectionID == section.id
? appearance.accent : appearance.secondaryText
)
)
.frame(maxWidth: .infinity)
.frame(height: 36)
.background(
selectedSectionID == section.id
? Color(uiColor: appearance.control) : Color.clear,
in: Circle()
)
NativeEmojiCategoryButton(
section: section,
appearance: appearance,
selection: categorySelection
) {
categorySelection.select(section.id)
// Category navigation is a frequent shortcut. Keeping it immediate
// means an in-progress proxy animation can never fight a finger drag.
proxy.scrollTo("section-\(section.id)", anchor: .top)
}
.frame(maxWidth: .infinity)
.buttonStyle(.plain)
.accessibilityLabel(section.title)
.accessibilityAddTraits(
selectedSectionID == section.id ? .isSelected : []
)
}
Divider()
.frame(height: 24)
Expand Down Expand Up @@ -302,12 +287,15 @@ struct NativeEmojiPickerView: View {
.scrollDismissesKeyboard(.interactively)
.onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in
guard tracksSelection else { return }
selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID(
order: data.sections.map(\.id),
offsets: offsets,
viewportTop: 0,
viewportBottom: offsets[nativeEmojiViewportBottomKey],
contentBottom: offsets[nativeEmojiContentBottomKey]
categorySelection.select(
NativeEmojiCategoryTracker.selectedSectionID(
order: data.sections.map(\.id),
offsets: offsets,
viewportTop: 0,
viewportBottom: offsets[nativeEmojiViewportBottomKey],
contentBottom: offsets[nativeEmojiContentBottomKey],
currentSelection: categorySelection.selectedSectionID
)
)
}
}
Expand Down Expand Up @@ -381,6 +369,38 @@ struct NativeEmojiPickerView: View {
}
}

/// Observes only the rail selection. Keeping this in a leaf view prevents a
/// scroll-frame highlight update from rebuilding the picker grid itself.
private struct NativeEmojiCategoryButton: View {
let section: NativeEmojiSection
let appearance: NativeEmojiPickerAppearance
@ObservedObject var selection: NativeEmojiCategorySelection
let onSelect: () -> Void

var body: some View {
let isSelected = selection.selectedSectionID == section.id
Button(action: onSelect) {
Image(systemName: section.systemImage)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(
Color(
uiColor: isSelected
? appearance.accent : appearance.secondaryText
)
)
.frame(maxWidth: .infinity)
.frame(height: 36)
.background(
isSelected ? Color(uiColor: appearance.control) : Color.clear,
in: Circle()
)
}
.buttonStyle(.plain)
.accessibilityLabel(section.title)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}

struct NativeEmojiRemoteImage: View {
let url: URL
let fallbackColor: UIColor
Expand Down
44 changes: 44 additions & 0 deletions mobile/ios/RunnerTests/RunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,50 @@ class RunnerTests: XCTestCase {
)
}

func testCategoryTrackerDoesNotFlickerBackAtPinnedHeaderBoundary() {
let order = ["people", "nature", "flags"]

// Nature has just become selected. A subsequent layout pass can briefly
// report its pinned header a couple of points below the boundary while the
// previous header is still pinned. Keep Nature selected through that
// transient frame instead of alternating the category rail.
XCTAssertEqual(
NativeEmojiCategoryTracker.selectedSectionID(
order: order,
offsets: ["people": 0, "nature": 2, "flags": 400],
viewportTop: 0,
currentSelection: "nature"
),
"nature"
)
}

func testCategoryTrackerReleasesBoundaryLatchOnRealUpwardScroll() {
let order = ["people", "nature", "flags"]

XCTAssertEqual(
NativeEmojiCategoryTracker.selectedSectionID(
order: order,
offsets: ["people": 0, "nature": 24, "flags": 424],
viewportTop: 0,
currentSelection: "nature"
),
"people"
)
}

func testCategoryTrackerReleasesSelectionWhenOldHeaderIsMissing() {
XCTAssertEqual(
NativeEmojiCategoryTracker.selectedSectionID(
order: ["people", "nature", "flags"],
offsets: ["people": 0, "flags": 400],
viewportTop: 0,
currentSelection: "nature"
),
"people"
)
}

func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws {
let maximumConcurrentDownloads = 3
let taskCount = 8
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'ime_metrics_settle_observer.dart';
import 'jump_to_latest_button.dart';
import 'jump_to_latest_switcher.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_long_press_region.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,8 @@ class _MessageList extends HookConsumerWidget {
await itemScrollController.scrollTo(
index: 0,
alignment: latestAlignment(),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
duration: jumpToLatestScrollDuration,
curve: jumpToLatestScrollCurve,
);
if (context.mounted && !hasUserScrolled.value) {
isAtLatest.value = true;
Expand Down Expand Up @@ -882,49 +882,14 @@ class _MessageList extends HookConsumerWidget {
right: 0,
bottom: navigationBottomInset + Grid.xs,
child: Center(
child: AnimatedSwitcher(
key: const ValueKey('channel-jump-to-latest-switcher'),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
reverseDuration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 160),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: _JumpToLatestScaleAnimation(animation),
alignment: Alignment.bottomCenter,
child: child,
),
),
child: !isJumpToLatestVisible.value
? const SizedBox.shrink(
key: ValueKey('channel-jump-to-latest-hidden'),
)
: JumpToLatestButton(
key: const ValueKey('channel-jump-to-latest'),
onPressed: scrollToLatest,
),
child: JumpToLatestSwitcher(
id: 'channel',
visible: isJumpToLatestVisible.value,
onPressed: scrollToLatest,
),
),
),
],
);
}
}

class _JumpToLatestScaleAnimation extends Animation<double>
with AnimationWithParentMixin<double> {
@override
final Animation<double> parent;

_JumpToLatestScaleAnimation(this.parent);

@override
double get value => parent.status == AnimationStatus.reverse
? parent.value
: 0.92 + (0.08 * parent.value);
}
5 changes: 3 additions & 2 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ const double _kChannelLabelInset =
/// sections while the labels stay on [_kChannelLabelInset].
const double _kDmAvatarSize = _kChannelIconSize;

const double _kTopSectionAvatarSize = 40.0;
const double _kTopSectionCommunityAvatarSize = 40.0;
const double _kTopSectionProfileAvatarSize = 36.0;
const double _kTopSectionBottomPadding = Grid.xxs;

/// The top section's avatars are 40dp circles, which fill their box edge to
Expand Down Expand Up @@ -344,7 +345,7 @@ class ChannelsPage extends HookConsumerWidget {
height: Grid.xl,
child: Center(
child: ProfileAvatar(
size: _kTopSectionAvatarSize,
size: _kTopSectionProfileAvatarSize,
onTap: () {
unawaited(HapticFeedback.lightImpact());
final route = _SettingsPageRoute(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ class _CommunityAvatar extends ConsumerWidget {
super.key,
required this.name,
this.relayUrl,
this.size = _kTopSectionAvatarSize,
this.size = _kTopSectionCommunityAvatarSize,
});

@override
Expand Down
8 changes: 3 additions & 5 deletions mobile/lib/features/channels/emoji_picker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,9 @@ class _EmojiPickerContent extends HookConsumerWidget {
activeSection.value = index;
if (!scrollController.hasClients) return;
final max = scrollController.position.maxScrollExtent;
scrollController.animateTo(
offsets[index].clamp(0.0, max),
duration: const Duration(milliseconds: 240),
curve: Curves.easeOutCubic,
);
// The rail is a frequent navigation shortcut. An instant jump cannot
// remain active and pull against a drag that begins immediately after it.
scrollController.jumpTo(offsets[index].clamp(0.0, max));
}

// Recompute only when the query or the underlying sets change — scanning
Expand Down
Loading
Loading