Skip to content

Conversation

@Nitin-100
Copy link
Contributor

@Nitin-100 Nitin-100 commented Nov 9, 2025

PR Description: ScrollView Snap Point Parity for Fabric

Description

Type of Change

  • Bug fix (non-breaking change which fixes missing functionality)
  • Feature addition (non-breaking change which adds missing property support)

Why

The Fabric Composition architecture was missing support for snap point properties in ScrollView components. Paper XAML fully supports these properties through SnapPointManagingContentControl, enabling various snapping behaviors for improved scroll UX.

This created feature gaps where:

  • Users couldn't enable paging behavior in Fabric ScrollViews (pagingEnabled)
  • No way to make scrolling snap to fixed intervals (snapToInterval)
  • No control over snap alignment relative to viewport (snapToAlignment)
  • Fabric ScrollView had snapToOffsets, snapToStart, snapToEnd but lacked the convenient interval-based options
  • Apps migrating from Paper to Fabric would lose these functionalities

What

Implemented complete snap point parity in Fabric ScrollView to match Paper XAML, iOS, and Android behavior:

1. pagingEnabled Property:

  • Snaps to viewport-size intervals (full page scrolling)
  • Generates snap points at: 0, viewport_size, 2×viewport_size, ... up to max scroll position

2. snapToInterval Property:

  • Snaps to fixed interval multiples
  • Generates snap points at: 0, interval, 2×interval, 3×interval, ... up to max scroll position
  • More flexible than pagingEnabled - works with any interval value

3. snapToAlignment Property:

  • Defines how snap points align relative to the viewport
  • Values: 'start' (default), 'center', 'end'
  • Only applies when snapToInterval is set

Priority Hierarchy (matches iOS/Android/Paper):

  1. snapToOffsets (highest) - explicit positions override everything
  2. snapToInterval - regular interval snapping
  3. pagingEnabled - viewport-sized interval snapping
  4. snapToStart/snapToEnd (lowest) - just start/end positions

Technical Implementation:

  1. IDL Interface (CompositionSwitcher.idl):
// Snap alignment enum matching XAML SnapPointsAlignment
enum SnapPointsAlignment {
  Near = 0,   // Start alignment (left/top)
  Center = 1, // Center alignment
  Far = 2,    // End alignment (right/bottom)
};

interface IScrollVisual {
    // ... existing methods ...
    void PagingEnabled(Boolean pagingEnabled);
    void SnapToInterval(Single interval);
    void SnapToAlignment(SnapPointsAlignment alignment);
}
  1. CompScrollerVisual Implementation (CompositionContextHelper.cpp):
struct CompScrollerVisual {
    bool m_pagingEnabled{false};
    float m_snapToInterval{0.0f};
    SnapPointsAlignment m_snapToAlignment{SnapPointsAlignment::Near};
    
    void PagingEnabled(bool pagingEnabled) {
        m_pagingEnabled = pagingEnabled;
        ConfigureSnapInertiaModifiers();
    }
    
    void SnapToInterval(float interval) {
        m_snapToInterval = interval;
        // snapToOffsets disables snapToInterval (highest priority)
        if (!m_snapToOffsets.empty()) {
            m_snapToInterval = 0.0f;
        }
        ConfigureSnapInertiaModifiers();
    }
    
    void SnapToAlignment(SnapPointsAlignment alignment) {
        m_snapToAlignment = alignment;
        ConfigureSnapInertiaModifiers();
    }
}
  1. Snap Point Generation (ConfigureSnapInertiaModifiers):
// Priority: snapToOffsets > snapToInterval > pagingEnabled
if (!m_snapToOffsets.empty()) {
    // Use explicit offsets (highest priority)
    if (m_snapToStart) snapPositions.push_back(0.0f);
    snapPositions.insert(..., m_snapToOffsets.begin(), m_snapToOffsets.end());
    
} else if (m_snapToInterval > 0.0f) {
    // Generate interval-based snap points with alignment
    float viewportSize = m_horizontal ? visualSize.x : visualSize.y;
    float maxScroll = m_horizontal ? max(contentSize.x - visualSize.x, 0) 
                                   : max(contentSize.y - visualSize.y, 0);
    
    // Calculate alignment offset
    float alignmentOffset = 0.0f;
    if (m_snapToAlignment == SnapPointsAlignment::Center) {
        alignmentOffset = -viewportSize / 2.0f;
    } else if (m_snapToAlignment == SnapPointsAlignment::Far) {
        alignmentOffset = -viewportSize;
    }
    
    // Generate snap points: alignmentOffset, interval+alignmentOffset, 2*interval+alignmentOffset, ...
    for (float pos = alignmentOffset; pos <= maxScroll; pos += m_snapToInterval) {
        if (pos >= 0.0f) snapPositions.push_back(pos);
    }
    
    // Ensure start and end are included
    if (snapPositions.empty() || snapPositions.front() > 0.0f) {
        snapPositions.insert(snapPositions.begin(), 0.0f);
    }
    if (snapPositions.back() < maxScroll) {
        snapPositions.push_back(maxScroll);
    }
    
} else if (m_pagingEnabled) {
    // Generate page-sized snap points
    float viewportSize = m_horizontal ? visualSize.x : visualSize.y;
    float maxScroll = /*...*/;
    
    for (float pos = 0.0f; pos <= maxScroll; pos += viewportSize) {
        snapPositions.push_back(pos);
    }
    
    // Ensure end position is included
    if (snapPositions.back() < maxScroll) {
        snapPositions.push_back(maxScroll);
    }
}
  1. Prop Handling (ScrollViewComponentView.cpp):
// pagingEnabled
if (!oldProps || oldViewProps.pagingEnabled != newViewProps.pagingEnabled) {
    m_scrollVisual.PagingEnabled(newViewProps.pagingEnabled);
}

// snapToInterval
if (!oldProps || oldViewProps.snapToInterval != newViewProps.snapToInterval) {
    m_scrollVisual.SnapToInterval(static_cast<float>(newViewProps.snapToInterval));
}

// snapToAlignment
if (!oldProps || oldViewProps.snapToAlignment != newViewProps.snapToAlignment) {
    using SnapPointsAlignment = Composition::Experimental::SnapPointsAlignment;
    auto alignment = SnapPointsAlignment::Near; // default "start"
    
    if (newViewProps.snapToAlignment == facebook::react::ScrollViewSnapToAlignment::Center) {
        alignment = SnapPointsAlignment::Center;
    } else if (newViewProps.snapToAlignment == facebook::react::ScrollViewSnapToAlignment::End) {
        alignment = SnapPointsAlignment::Far;
    }
    
    m_scrollVisual.SnapToAlignment(alignment);
}

This implementation uses Fabric's existing InteractionTracker-based snap point system, generating InteractionTrackerInertiaRestingValue instances for each snap position with appropriate condition and resting expressions.

Screenshots

No screenshots needed. Implements standard scroll snapping behavior matching Paper/iOS/Android.

Testing

Will be tested in playground-composition.sln:

pagingEnabled Testing:

  1. Horizontal paging:

    • <ScrollView horizontal pagingEnabled>
    • Add content wider than viewport
    • Expected: Snaps to viewport width intervals (0, 500, 1000, ...)
  2. Vertical paging:

    • <ScrollView pagingEnabled>
    • Add content taller than viewport
    • Expected: Snaps to viewport height intervals

snapToInterval Testing:

  1. Fixed interval snapping:

    • <ScrollView snapToInterval={100}>
    • Expected: Snaps to 0, 100, 200, 300, ... up to maxScroll
  2. With snapToAlignment="start" (default):

    • <ScrollView snapToInterval={100} snapToAlignment="start">
    • Expected: Snap points at 0, 100, 200, ... (no offset)
  3. With snapToAlignment="center":

    • <ScrollView snapToInterval={100} snapToAlignment="center">
    • Viewport = 500px
    • Expected: Snap points at 0, -150 (clamped to 0), 100-250=-150, 200-250=-50, 300-250=50, 400-250=150, ...
    • Aligns snap point at viewport center
  4. With snapToAlignment="end":

    • <ScrollView snapToInterval={100} snapToAlignment="end">
    • Viewport = 500px
    • Expected: Snap points at 0, -400 (clamped to 0), 100-500=-400, 200-500=-300, ..., 500-500=0, 600-500=100, ...
    • Aligns snap point at viewport end

Priority Testing:

  1. snapToOffsets overrides snapToInterval:

    • <ScrollView snapToInterval={100} snapToOffsets={[50, 150, 300]}>
    • Expected: Snaps to 0 (if snapToStart), 50, 150, 300 only (interval ignored)
  2. snapToOffsets overrides pagingEnabled:

    • <ScrollView pagingEnabled snapToOffsets={[50, 150, 300]}>
    • Expected: Snaps to explicit offsets only (paging ignored)
  3. snapToInterval overrides pagingEnabled:

    • <ScrollView pagingEnabled snapToInterval={100}>
    • Expected: Snaps to interval positions (100, 200, ...), not viewport-size positions

Edge Cases:

  1. Content smaller than viewport:

    • Expected: Snaps to 0 only
  2. Content not exact multiple:

    • snapToInterval=150, maxScroll=400
    • Expected: Snaps to 0, 150, 300, 400
  3. Dynamic resizing:

    • Change viewport size during scroll
    • Expected: Snap points regenerate correctly

Compare with Paper:

  • Test same props in playground.sln (Paper XAML)
  • Verify identical snapping behavior

Changelog

Should this change be included in the release notes: yes

Implemented snap point parity for Fabric ScrollView:

  • pagingEnabled: Enables page-by-page scrolling at viewport-size intervals
  • snapToInterval: Snaps to fixed interval multiples for custom pagination
  • snapToAlignment: Controls snap point alignment ('start', 'center', 'end')

Matches iOS/Android/Paper behavior with correct priority hierarchy: snapToOffsets > snapToInterval > pagingEnabled. Users can now use all standard React Native scroll snapping features in Fabric architecture.

Related Work

  • Paper XAML: Uses SnapPointManagingContentControl with GetRegularSnapPoints() returning interval/viewport size
  • Fabric: Uses InteractionTracker with explicit InteractionTrackerInertiaRestingValue for each snap position
  • iOS/Android: Official React Native documentation confirms priority and behavior
  • Priority Matching: snapToOffsets > snapToInterval > pagingEnabled (per RN docs)
  • Both Paper and Fabric implementations achieve identical user-facing behavior

Breaking Changes

None. These are additive features with sensible defaults:

  • pagingEnabled defaults to false
  • snapToInterval defaults to 0 (disabled)
  • snapToAlignment defaults to 'start' (Near)

Existing ScrollView behavior unchanged.

Additional Notes

React Native Official Behavior (from reactnative.dev):

  1. pagingEnabled:

    • "When true, the scroll view stops on multiples of the scroll view's size when scrolling"
    • Simple page-by-page scrolling
  2. snapToInterval:

    • "Causes the scroll view to stop at multiples of the value of snapToInterval"
    • "Used for paginating through children that have lengths smaller than the scroll view"
    • "Typically used in combination with snapToAlignment and decelerationRate='fast'"
    • "Overrides less configurable pagingEnabled prop"
  3. snapToAlignment:

    • "When snapToInterval is set, snapToAlignment will define the relationship of the snapping to the scroll view"
    • 'start': Align snap at left (horizontal) or top (vertical)
    • 'center': Align snap in center
    • 'end': Align snap at right (horizontal) or bottom (vertical)
  4. snapToOffsets:

    • "Causes the scroll view to stop at the defined offsets"
    • "Overrides less configurable pagingEnabled and snapToInterval props"

Implementation Notes:

  • Paper approach: Returns snap interval to XAML, which handles snap point generation automatically
  • Fabric approach: Explicitly generates all snap point positions for InteractionTracker
  • Alignment calculation:
    • Start: no offset
    • Center: offset = -viewport/2
    • End: offset = -viewport
    • Positions < 0 are clamped to 0
  • Dynamic updates: Snap points regenerate automatically on viewport size or content size changes
  • Performance: Uses Composition API's hardware-accelerated InteractionTracker for smooth snapping

@Nitin-100 Nitin-100 requested review from a team as code owners November 9, 2025 16:19
Nitin Chaudhary added 5 commits November 10, 2025 12:11
- Add pagingEnabled boolean member to CompScrollerVisual
- Implement PagingEnabled() method in IScrollVisual interface
- Generate viewport-sized snap points when pagingEnabled=true
- Wire up pagingEnabled prop in ScrollViewComponentView::updateProps()
- Achieves parity with Paper ScrollView pagingEnabled support
- Added bool m_pagingEnabled member to CompScrollerVisual
- Implemented PagingEnabled() method in IScrollVisual interface
- Generate viewport-sized snap points when pagingEnabled=true
- Wired up prop handling in ScrollViewComponentView
- snapToOffsets disables pagingEnabled (matches Paper/iOS/Android behavior)
- Excludes snapToEnd when pagingEnabled to avoid conflicts
- Validates viewport size before generating snap points
- Reconfigures snap points when size changes and paging is enabled

Achieves full parity with Paper XAML and cross-platform React Native behavior.
Added complete snap point parity with Paper/iOS/Android:

1. pagingEnabled: Snaps to viewport-size intervals
   - Already implemented in previous commit
   - Full page-by-page scrolling behavior

2. snapToInterval: Snaps to fixed interval multiples
   - More flexible than pagingEnabled
   - Works with any interval value
   - Generates snap points at: 0, interval, 2*interval, 3*interval, ... maxScroll

3. snapToAlignment: Controls snap point alignment
   - Values: 'start' (Near), 'center' (Center), 'end' (Far)
   - Only applies when snapToInterval is set
   - Start: no offset (default)
   - Center: offset = -viewport/2
   - End: offset = -viewport

Priority hierarchy matches React Native official behavior:
- snapToOffsets (highest) > snapToInterval > pagingEnabled > snapToStart/snapToEnd

Implementation:
- Added SnapPointsAlignment enum to CompositionSwitcher.idl
- Added m_snapToInterval and m_snapToAlignment members to CompScrollerVisual
- Implemented SnapToInterval() and SnapToAlignment() methods
- Updated ConfigureSnapInertiaModifiers() with priority-based snap point generation
- Wired props in ScrollViewComponentView::updateProps()
- snapToOffsets disables both snapToInterval and pagingEnabled

Matches Paper's SnapPointManagingContentControl behavior where:
- GetRegularSnapPoints() returns m_interval when set
- SnapToOffsets() sets m_interval = 0 (disables it)
- XAML SnapPointsAlignment maps to: Near=start, Center=center, Far=end
@Nitin-100 Nitin-100 force-pushed the nitin/parity-fabric/scrollview-pagingenabled branch from 208cc0f to f357995 Compare November 10, 2025 06:45
@Nitin-100
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 2 pipeline(s).

@Nitin-100 Nitin-100 requested a review from acoates-ms November 11, 2025 03:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant