From 1433041c3f1712c790262f7d720db2181a7b9ffe Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Wed, 18 Aug 2021 11:05:24 -0400 Subject: [PATCH 01/11] Implement native inverted behaviors for ScrollView This PR modifies ScrollViewManager and VirtualizedList to work around the limitations of the RN core approach to list inversion. Specifically, the RN core approach to list inversion uses scroll axis transforms to flip the list content. While this creates the visual appearance of an inverted list, scrolling the list via keyboard or mouse wheel results in inverted scroll direction. To accomplish native inverted scroll behaviors, we focused on four expected behaviors of inverted lists: 1. When content loads "above" the view port in an inverted list, the visual content must remain anchored (as if the content renders below it in a non-inverted list). 2. When scrolled to the "start" of an inverted list (in absolute terms, the bottom of the scroll view), content appended to the start of the list must scroll into view synchronously (i.e., no delay between content rendering and view port changing). 3. When content renders on screen, it must render from bottom to top, so render passes that take multiple frames do not appear to scroll to the start of the list. 4. When imperatively scrolling to the "start" of the content, we must always scroll to the latest content, even if the content rendered after the scroll-to-start animation already began. For 1., we leverage the XAML `CanBeScrollAnchor` property on each top-level item in the list view. While this is an imperfect solution (re-rendering of this content while in the view port can result in view port shifts as new content renders above), it is a good trade-off of performance and functionality. For 2., we leverage the XAML `HorizontalAnchorRatio` and `VerticalAnchorRatio` properties. XAML has a special case for inverted lists when setting these property values to `1.0`. It instructs XAML to synchronously scroll to and render new content when scrolled to the bottom edge of the ScrollViewer. For 3., we leverage Yoga's implementation of `flexDirection: column-reverse` and `flexDirection: row-reverse` to ensure content is rendered from bottom to top. For 4., we implemented `ScrollViewViewChanger` to continuously check if the target scroll offset has changed since starting an animated scroll-to-end operation. If the target scroll offset no longer matches the scrollable extent of the ScrollViewer, we update the target offset by calling `ChangeView` again. Fixes #4098 --- .../Microsoft.ReactNative.vcxproj | 2 + .../Microsoft.ReactNative.vcxproj.filters | 6 ++ .../Impl/ScrollViewUWPImplementation.cpp | 8 ++ .../Views/Impl/ScrollViewUWPImplementation.h | 2 + .../Views/Impl/ScrollViewViewChanger.cpp | 98 +++++++++++++++++++ .../Views/Impl/ScrollViewViewChanger.h | 34 +++++++ .../Impl/SnapPointManagingContentControl.cpp | 8 ++ .../Impl/SnapPointManagingContentControl.h | 12 +++ .../Views/ScrollContentViewManager.cpp | 35 +++++++ .../Views/ScrollContentViewManager.h | 13 ++- .../Views/ScrollViewManager.cpp | 57 ++++++++++- .../Views/ScrollViewManager.h | 9 ++ 12 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp create mode 100644 vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h diff --git a/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj b/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj index 9c2f53ef425..a32b8df688f 100644 --- a/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj +++ b/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj @@ -344,6 +344,7 @@ + @@ -568,6 +569,7 @@ + diff --git a/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj.filters b/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj.filters index f1fc11b1bcd..00499e60088 100644 --- a/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj.filters +++ b/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj.filters @@ -176,6 +176,9 @@ Views\Impl + + Views\Impl + Views\Impl @@ -541,6 +544,9 @@ Views\Impl + + Views\Impl + Views\Impl diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.cpp b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.cpp index ade44097765..3466967f814 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.cpp +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.cpp @@ -15,10 +15,18 @@ ScrollViewUWPImplementation::ScrollViewUWPImplementation(const winrt::ScrollView m_scrollViewer = winrt::make_weak(scrollViewer); } +void ScrollViewUWPImplementation::ContentAnchoringEnabled(bool enabled) { + ScrollViewerSnapPointManager()->ContentAnchoringEnabled(enabled); +} + void ScrollViewUWPImplementation::SetHorizontal(bool horizontal) { ScrollViewerSnapPointManager()->SetHorizontal(horizontal); } +void ScrollViewUWPImplementation::SetInverted(bool inverted) { + ScrollViewerSnapPointManager()->SetInverted(inverted); +} + void ScrollViewUWPImplementation::SnapToInterval(float interval) { ScrollViewerSnapPointManager()->SnapToInterval(interval); } diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.h b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.h index c62b7bd0800..e95bb0f1d82 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.h +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.h @@ -23,7 +23,9 @@ class ScrollViewUWPImplementation { public: ScrollViewUWPImplementation(const winrt::ScrollViewer &scrollViewer); + void ContentAnchoringEnabled(bool enabled); void SetHorizontal(bool isHorizontal); + void SetInverted(bool isInverted); void SnapToInterval(float interval); void SnapToStart(bool snapToStart); void SnapToEnd(bool snapToEnd); diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp new file mode 100644 index 00000000000..3dbf9d60d62 --- /dev/null +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "pch.h" + +#include +#include "ScrollViewUWPImplementation.h" +#include "ScrollViewViewChanger.h" +#include "SnapPointManagingContentControl.h" + +namespace Microsoft::ReactNative { + +constexpr const double SCROLL_EPSILON = 1.0; + +void ScrollViewViewChanger::Horizontal(bool horizontal) { + m_horizontal = horizontal; +} + +void ScrollViewViewChanger::Inverted(bool inverted) { + m_inverted = inverted; +} + +void ScrollViewViewChanger::ScrollToEnd(const xaml::Controls::ScrollViewer &scrollViewer, bool animated) { + m_isScrollingToEnd = animated && m_inverted; + if (m_isScrollingToEnd) { + // Disable scroll anchoring while scrolling to end + ScrollViewUWPImplementation(scrollViewer).ContentAnchoringEnabled(false); + SetContentScrollAnchors(scrollViewer, false); + } + + if (m_horizontal) { + scrollViewer.ChangeView(scrollViewer.ScrollableWidth(), nullptr, nullptr, !animated); + } else { + scrollViewer.ChangeView(nullptr, scrollViewer.ScrollableHeight(), nullptr, !animated); + } +} + +void ScrollViewViewChanger::OnViewChanging( + const xaml::Controls::ScrollViewer &scrollViewer, + const xaml::Controls::ScrollViewerViewChangingEventArgs &args) { + // For non-inverted views, a ScrollViewer.ViewChanging event always emits an `onScroll` event + if (m_inverted) { + // For inverted views, we need to detect if we're scrolling to or away from the bottom edge to enable or disable + // view anchoring + const auto scrollingToEnd = + IsScrollingToEnd(scrollViewer, args.NextView().HorizontalOffset(), args.NextView().VerticalOffset()); + + // Do not update scroll anchoring while scrolling to end + if (!m_isScrollingToEnd) { + ScrollViewUWPImplementation(scrollViewer).ContentAnchoringEnabled(!scrollingToEnd); + if (m_wasViewAnchoringEnabled && scrollingToEnd) { + // If scrolling to anchor edge, turn off view anchoring + SetContentScrollAnchors(scrollViewer, false); + m_wasViewAnchoringEnabled = false; + } else if (!m_wasViewAnchoringEnabled && !scrollingToEnd) { + // If scrolling away from anchor edge, turn on view anchoring + SetContentScrollAnchors(scrollViewer, true); + m_wasViewAnchoringEnabled = true; + } + } + } +} + +void ScrollViewViewChanger::OnViewChanged( + const xaml::Controls::ScrollViewer &scrollViewer, + const xaml::Controls::ScrollViewerViewChangedEventArgs &args) { + // Stop tracking scroll-to-end once the ScrollView comes to rest + if (!args.IsIntermediate() && m_isScrollingToEnd) { + m_isScrollingToEnd = false; + if (!IsScrollingToEnd(scrollViewer, scrollViewer.HorizontalOffset(), scrollViewer.VerticalOffset())) { + ScrollViewUWPImplementation(scrollViewer).ContentAnchoringEnabled(true); + SetContentScrollAnchors(scrollViewer, true); + } + } +} + +void ScrollViewViewChanger::SetContentScrollAnchors(const xaml::Controls::ScrollViewer &scrollViewer, bool enabled) { + const auto snapPointManager = scrollViewer.Content().as(); + auto panel = snapPointManager->Content().as(); + for (auto child : panel.Children()) { + const auto childElement = child.as(); + if (enabled) { + childElement.CanBeScrollAnchor(true); + } else { + childElement.ClearValue(xaml::UIElement::CanBeScrollAnchorProperty()); + } + } +} + +bool ScrollViewViewChanger::IsScrollingToEnd( + const xaml::Controls::ScrollViewer &scrollViewer, + double horizontalOffset, + double verticalOffset) { + return m_horizontal ? horizontalOffset > (scrollViewer.ScrollableWidth() - SCROLL_EPSILON) + : verticalOffset > (scrollViewer.ScrollableHeight() - SCROLL_EPSILON); +} + +} // namespace Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h new file mode 100644 index 00000000000..f8f1be4e278 --- /dev/null +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#pragma once + +namespace Microsoft::ReactNative { + +class ScrollViewViewChanger { + public: + void Horizontal(bool horizontal); + void Inverted(bool inverted); + + void ScrollToEnd(const xaml::Controls::ScrollViewer &scrollViewer, bool animated); + + void OnViewChanging( + const xaml::Controls::ScrollViewer &scrollViewer, + const xaml::Controls::ScrollViewerViewChangingEventArgs &args); + void OnViewChanged( + const xaml::Controls::ScrollViewer &scrollViewer, + const xaml::Controls::ScrollViewerViewChangedEventArgs &args); + + private: + bool m_inverted{false}; + bool m_horizontal{false}; + bool m_isScrollingToEnd{false}; + bool m_wasViewAnchoringEnabled{false}; + double m_targetScrollToEndOffset{0.0}; + + static void SetContentScrollAnchors(const xaml::Controls::ScrollViewer &scrollViewer, bool enabled); + bool + IsScrollingToEnd(const xaml::Controls::ScrollViewer &scrollViewer, double horizontalOffset, double verticalOffset); +}; + +} // namespace Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Views/Impl/SnapPointManagingContentControl.cpp b/vnext/Microsoft.ReactNative/Views/Impl/SnapPointManagingContentControl.cpp index 702fe8976b6..91c2806bd1d 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/SnapPointManagingContentControl.cpp +++ b/vnext/Microsoft.ReactNative/Views/Impl/SnapPointManagingContentControl.cpp @@ -12,6 +12,10 @@ SnapPointManagingContentControl::SnapPointManagingContentControl() { return winrt::make_self(); } +void SnapPointManagingContentControl::ContentAnchoringEnabled(bool enabled) { + m_contentAnchoringEnabled = enabled; +} + void SnapPointManagingContentControl::SnapToInterval(float interval) { if (interval != m_interval) { m_interval = interval; @@ -124,6 +128,10 @@ void SnapPointManagingContentControl::SetHorizontal(bool horizontal) { } } +void SnapPointManagingContentControl::SetInverted(bool inverted) { + m_inverted = inverted; +} + void SnapPointManagingContentControl::SetWidthBounds(float startWidth, float endWidth) { const auto update = [this, startWidth, endWidth]() { const auto endUpdated = [this, endWidth]() { diff --git a/vnext/Microsoft.ReactNative/Views/Impl/SnapPointManagingContentControl.h b/vnext/Microsoft.ReactNative/Views/Impl/SnapPointManagingContentControl.h index 609b27c2c42..bd24b790ddb 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/SnapPointManagingContentControl.h +++ b/vnext/Microsoft.ReactNative/Views/Impl/SnapPointManagingContentControl.h @@ -23,6 +23,7 @@ class SnapPointManagingContentControl static winrt::com_ptr Create(); // ScrollView Implementation + void ContentAnchoringEnabled(bool enabled); void SnapToInterval(float interval); void SnapToOffsets(const winrt::IVectorView &offsets); void SnapToStart(bool snapToStart); @@ -49,14 +50,23 @@ class SnapPointManagingContentControl // Helpers void SetHorizontal(bool horizontal); + void SetInverted(bool inverted); void SetHeightBounds(float startHeight, float endHeight); void SetWidthBounds(float startWidth, float endWidth); void SetViewportSize(float scaledViewportWidth, float scaledviewportHeight); + bool IsContentAnchoringEnabled() { + return m_contentAnchoringEnabled; + } + bool IsHorizontal() { return m_horizontal; } + bool IsInverted() { + return m_inverted; + } + private: float m_interval{0.0f}; winrt::IVectorView m_offsets{}; @@ -66,7 +76,9 @@ class SnapPointManagingContentControl winrt::event> m_horizontalSnapPointsChangedEventSource; winrt::event> m_verticalSnapPointsChangedEventSource; + bool m_contentAnchoringEnabled{false}; bool m_horizontal{false}; + bool m_inverted{false}; float m_startHeight{0}; float m_startWidth{0}; float m_endHeight{INFINITY}; diff --git a/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp b/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp index c3e42abf9e0..6542042ad85 100644 --- a/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp +++ b/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp @@ -5,6 +5,10 @@ #include "ScrollContentViewManager.h" +#include +#include "Impl/SnapPointManagingContentControl.h" +#include "ViewPanel.h" + namespace Microsoft::ReactNative { ScrollContentViewManager::ScrollContentViewManager(const Mso::React::IReactContext &context) : Super(context) {} @@ -13,4 +17,35 @@ const wchar_t *ScrollContentViewManager::GetName() const { return L"RCTScrollContentView"; } +XamlView ScrollContentViewManager::CreateViewCore( + int64_t /*tag*/, + const winrt::Microsoft::ReactNative::JSValueObject & /*props*/) { + auto panel = winrt::make(); + panel.VerticalAlignment(xaml::VerticalAlignment::Stretch); + panel.HorizontalAlignment(xaml::HorizontalAlignment::Stretch); + return panel.as(); +} + +void ScrollContentViewManager::AddView(const XamlView &parent, const XamlView &child, int64_t index) { + // All top-level children of inverted ScrollView content will be anchor candidates, unless scrolled to the top. + auto childElement = child.as(); + auto viewParent = parent.as().Parent(); + if (viewParent) { + const auto scrollViewContentControl = viewParent.as(); + if (scrollViewContentControl->IsInverted() && scrollViewContentControl->IsContentAnchoringEnabled()) { + childElement.CanBeScrollAnchor(true); + } + } + + parent.as().InsertAt(static_cast(index), childElement); +} + +void ScrollContentViewManager::RemoveAllChildren(const XamlView &parent) { + parent.as()->Clear(); +} + +void ScrollContentViewManager::RemoveChildAt(const XamlView &parent, int64_t index) { + parent.as()->RemoveAt(static_cast(index)); +} + } // namespace Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.h b/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.h index d7561bb093a..aacb49485f9 100644 --- a/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.h +++ b/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.h @@ -3,17 +3,24 @@ #pragma once -#include +#include namespace Microsoft::ReactNative { -class ScrollContentViewManager : public ViewViewManager { - using Super = ViewViewManager; +class ScrollContentViewManager : public FrameworkElementViewManager { + using Super = FrameworkElementViewManager; public: ScrollContentViewManager(const Mso::React::IReactContext &context); const wchar_t *GetName() const override; + + void AddView(const XamlView &parent, const XamlView &child, int64_t index) override; + void RemoveAllChildren(const XamlView &parent) override; + void RemoveChildAt(const XamlView &parent, int64_t index) override; + + protected: + XamlView CreateViewCore(int64_t tag, const winrt::Microsoft::ReactNative::JSValueObject &props) override; }; } // namespace Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp b/vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp index e8bfc9b0b61..25f3e7692a3 100644 --- a/vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp +++ b/vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp @@ -7,6 +7,7 @@ #include #include #include "Impl/ScrollViewUWPImplementation.h" +#include "Impl/ScrollViewViewChanger.h" #include "ScrollViewManager.h" using namespace winrt::Microsoft::ReactNative; @@ -34,6 +35,8 @@ class ScrollViewShadowNode : public ShadowNodeBase { void createView(const winrt::Microsoft::ReactNative::JSValueObject &) override; void updateProperties(winrt::Microsoft::ReactNative::JSValueObject &props) override; + bool IsInverted() const; + private: void AddHandlers(const winrt::ScrollViewer &scrollViewer); void EmitScrollEvent( @@ -55,10 +58,13 @@ class ScrollViewShadowNode : public ShadowNodeBase { bool m_isScrollingFromInertia = false; bool m_isScrolling = false; bool m_isHorizontal = false; + bool m_isInverted = false; bool m_isScrollingEnabled = true; bool m_changeViewAfterLoaded = false; bool m_dismissKeyboardOnDrag = false; + ScrollViewViewChanger m_viewChanger; + std::shared_ptr m_SIPEventHandler; xaml::FrameworkElement::SizeChanged_revoker m_scrollViewerSizeChangedRevoker{}; @@ -90,10 +96,7 @@ void ScrollViewShadowNode::dispatchCommand( scrollViewer.ChangeView(x, y, nullptr, !animated /*disableAnimation*/); } else if (commandId == ScrollViewCommands::ScrollToEnd) { bool animated = commandArgs[0].AsBoolean(); - if (m_isHorizontal) - scrollViewer.ChangeView(scrollViewer.ScrollableWidth(), nullptr, nullptr, !animated /*disableAnimation*/); - else - scrollViewer.ChangeView(nullptr, scrollViewer.ScrollableHeight(), nullptr, !animated /*disableAnimation*/); + m_viewChanger.ScrollToEnd(scrollViewer, animated); } } @@ -112,13 +115,15 @@ void ScrollViewShadowNode::createView(const winrt::Microsoft::ReactNative::JSVal }); m_scrollViewerViewChangedRevoker = scrollViewer.ViewChanged( - winrt::auto_revoke, [this, scrollViewUWPImplementation](const auto &sender, const auto & /*args*/) { + winrt::auto_revoke, [this, scrollViewUWPImplementation](const auto &sender, const auto &args) { const auto scrollViewerNotNull{sender.as()}; const auto zoomFactor{scrollViewerNotNull.ZoomFactor()}; if (m_zoomFactor != zoomFactor) { m_zoomFactor = zoomFactor; scrollViewUWPImplementation.UpdateScrollableSize(); } + + m_viewChanger.OnViewChanged(scrollViewerNotNull, args); }); m_contentSizeChangedRevoker = scrollViewUWPImplementation.ScrollViewerSnapPointManager()->SizeChanged( @@ -226,6 +231,20 @@ void ScrollViewShadowNode::updateProperties(winrt::Microsoft::ReactNative::JSVal if (valid) { ScrollViewUWPImplementation(scrollViewer).PagingEnabled(pagingEnabled); } + } else if (propertyName == "inverted") { + const auto [valid, inverted] = getPropertyAndValidity(propertyValue, false); + if (valid) { + m_isInverted = inverted; + m_viewChanger.Inverted(inverted); + ScrollViewUWPImplementation(scrollViewer).SetInverted(inverted); + if (inverted) { + scrollViewer.HorizontalAnchorRatio(1.0); + scrollViewer.VerticalAnchorRatio(1.0); + } else { + scrollViewer.ClearValue(winrt::ScrollViewer::HorizontalAnchorRatioProperty()); + scrollViewer.ClearValue(winrt::ScrollViewer::VerticalAnchorRatioProperty()); + } + } } } @@ -233,6 +252,10 @@ void ScrollViewShadowNode::updateProperties(winrt::Microsoft::ReactNative::JSVal m_updating = false; } +bool ScrollViewShadowNode::IsInverted() const { + return m_isInverted; +} + void ScrollViewShadowNode::AddHandlers(const winrt::ScrollViewer &scrollViewer) { m_scrollViewerViewChangingRevoker = scrollViewer.ViewChanging(winrt::auto_revoke, [this](const auto &sender, const auto &args) { @@ -261,6 +284,8 @@ void ScrollViewShadowNode::AddHandlers(const winrt::ScrollViewer &scrollViewer) CoalesceType::Durable); } + m_viewChanger.OnViewChanging(scrollViewerNotNull, args); + EmitScrollEvent( scrollViewerNotNull, m_tag, @@ -445,6 +470,7 @@ void ScrollViewManager::GetNativeProps(const winrt::Microsoft::ReactNative::IJSV winrt::Microsoft::ReactNative::WriteProperty(writer, L"snapToEnd", L"boolean"); winrt::Microsoft::ReactNative::WriteProperty(writer, L"pagingEnabled", L"boolean"); winrt::Microsoft::ReactNative::WriteProperty(writer, L"keyboardDismissMode", L"string"); + winrt::Microsoft::ReactNative::WriteProperty(writer, L"inverted", L"boolean"); } ShadowNode *ScrollViewManager::createShadow() const { @@ -496,6 +522,27 @@ XamlView ScrollViewManager::CreateViewCore(int64_t /*tag*/, const winrt::Microso return scrollViewer; } +void ScrollViewManager::SetLayoutProps( + ShadowNodeBase &nodeToUpdate, + const XamlView &viewToUpdate, + float left, + float top, + float width, + float height) { + // ScrollViewer selects an anchor during the Arrange phase of layout. + // If you do not call InvalidateArrange whenever a new child is added + // to the ScrollViewer content, the anchor behavior does not work. + // + // While this call fires too frequently resulting in unnecessary + // calls to invalidate arrange, it is the only sure-fire way to call + // InvalidateArrange any time any descendent layout changes. + if (static_cast(nodeToUpdate).IsInverted()) { + viewToUpdate.as().InvalidateArrange(); + } + + Super::SetLayoutProps(nodeToUpdate, viewToUpdate, left, top, width, height); +} + void ScrollViewManager::AddView(const XamlView &parent, const XamlView &child, [[maybe_unused]] int64_t index) { assert(index == 0); diff --git a/vnext/Microsoft.ReactNative/Views/ScrollViewManager.h b/vnext/Microsoft.ReactNative/Views/ScrollViewManager.h index 26d50b09a92..adcfc23c903 100644 --- a/vnext/Microsoft.ReactNative/Views/ScrollViewManager.h +++ b/vnext/Microsoft.ReactNative/Views/ScrollViewManager.h @@ -23,6 +23,15 @@ class ScrollViewManager : public ControlViewManager { ShadowNode *createShadow() const override; + // Yoga Layout + void SetLayoutProps( + ShadowNodeBase &nodeToUpdate, + const XamlView &viewToUpdate, + float left, + float top, + float width, + float height) override; + void AddView(const XamlView &parent, const XamlView &child, int64_t index) override; void RemoveAllChildren(const XamlView &parent) override; void RemoveChildAt(const XamlView &parent, int64_t index) override; From 2e92792feb3657dbbefd735aabe2baa02e7ff74a Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Wed, 18 Aug 2021 14:10:13 -0400 Subject: [PATCH 02/11] Change files --- ...tualized-list-ae7563e8-178f-4f0d-a565-9e11f32ad2e6.json | 7 +++++++ ...ative-windows-0e0722f1-9253-4400-b44f-9ac6159a3e61.json | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 change/@react-native-windows-virtualized-list-ae7563e8-178f-4f0d-a565-9e11f32ad2e6.json create mode 100644 change/react-native-windows-0e0722f1-9253-4400-b44f-9ac6159a3e61.json diff --git a/change/@react-native-windows-virtualized-list-ae7563e8-178f-4f0d-a565-9e11f32ad2e6.json b/change/@react-native-windows-virtualized-list-ae7563e8-178f-4f0d-a565-9e11f32ad2e6.json new file mode 100644 index 00000000000..858c222a664 --- /dev/null +++ b/change/@react-native-windows-virtualized-list-ae7563e8-178f-4f0d-a565-9e11f32ad2e6.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Implement native inverted behaviors for ScrollView", + "packageName": "@react-native-windows/virtualized-list", + "email": "erozell@outlook.com", + "dependentChangeType": "patch" +} diff --git a/change/react-native-windows-0e0722f1-9253-4400-b44f-9ac6159a3e61.json b/change/react-native-windows-0e0722f1-9253-4400-b44f-9ac6159a3e61.json new file mode 100644 index 00000000000..6ef181be56f --- /dev/null +++ b/change/react-native-windows-0e0722f1-9253-4400-b44f-9ac6159a3e61.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Implement native inverted behaviors for ScrollView", + "packageName": "react-native-windows", + "email": "erozell@outlook.com", + "dependentChangeType": "patch" +} From c7f0aa0c1f8c90dc00cdbc74b88d8245928b0dc0 Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Wed, 27 Oct 2021 14:51:03 -0400 Subject: [PATCH 03/11] Ensure tail spacer is not used as a scroll anchor We previously stopped development on the native inverted approach for Windows that would use anchoring and flexDirection to implement a truly reversed list (as opposed to a list flipped via transform) due to a bug where the anchoring would cause the view to "ride" to the top of the ScrollViewer when you scrolled into the virtualized tail spacer. This change introduces a new View prop called `overflowAnchor` and uses this prop to ensure that the tail spacer can never be anchored. --- .../Views/Impl/ScrollViewViewChanger.cpp | 11 ++++--- .../Views/ScrollContentViewManager.cpp | 5 ++- .../Views/ViewViewManager.cpp | 31 +++++++++++++++++++ .../Views/ViewViewManager.h | 2 ++ .../View/ReactNativeViewAttributes.windows.js | 1 + .../Components/View/ViewPropTypes.windows.js | 1 + .../Components/View/ViewWindowsProps.ts | 5 +++ 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp index 3dbf9d60d62..ea1240c3eee 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp @@ -4,6 +4,7 @@ #include "pch.h" #include +#include #include "ScrollViewUWPImplementation.h" #include "ScrollViewViewChanger.h" #include "SnapPointManagingContentControl.h" @@ -79,10 +80,12 @@ void ScrollViewViewChanger::SetContentScrollAnchors(const xaml::Controls::Scroll auto panel = snapPointManager->Content().as(); for (auto child : panel.Children()) { const auto childElement = child.as(); - if (enabled) { - childElement.CanBeScrollAnchor(true); - } else { - childElement.ClearValue(xaml::UIElement::CanBeScrollAnchorProperty()); + if (winrt::unbox_value(childElement.GetValue(ViewViewManager::CanBeScrollAnchorProperty()))) { + if (enabled) { + childElement.CanBeScrollAnchor(true); + } else { + childElement.ClearValue(xaml::UIElement::CanBeScrollAnchorProperty()); + } } } } diff --git a/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp b/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp index 6542042ad85..f1533cea5cb 100644 --- a/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp +++ b/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp @@ -6,6 +6,7 @@ #include "ScrollContentViewManager.h" #include +#include #include "Impl/SnapPointManagingContentControl.h" #include "ViewPanel.h" @@ -33,7 +34,9 @@ void ScrollContentViewManager::AddView(const XamlView &parent, const XamlView &c if (viewParent) { const auto scrollViewContentControl = viewParent.as(); if (scrollViewContentControl->IsInverted() && scrollViewContentControl->IsContentAnchoringEnabled()) { - childElement.CanBeScrollAnchor(true); + if (winrt::unbox_value(child.GetValue(ViewViewManager::CanBeScrollAnchorProperty()))) { + childElement.CanBeScrollAnchor(true); + } } } diff --git a/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp b/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp index 157b74e45eb..b7435316f6f 100644 --- a/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp +++ b/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp @@ -33,6 +33,10 @@ using namespace facebook::react; +namespace winrt { +using namespace winrt::Windows::UI::Xaml::Interop; +} + namespace Microsoft::ReactNative { // ViewShadowNode @@ -352,6 +356,20 @@ bool TryUpdateBorderProperties( ViewViewManager::ViewViewManager(const Mso::React::IReactContext &context) : Super(context) {} +const winrt::TypeName viewViewManagerTypeName{ + winrt::hstring{L"ViewViewManager"}, + winrt::TypeKind::Metadata}; + +/*static*/ xaml::DependencyProperty ViewViewManager::CanBeScrollAnchorProperty() { + static xaml::DependencyProperty s_canBeScrollAnchorProperty = xaml::DependencyProperty::RegisterAttached( + L"CanBeScrollAnchor", + winrt::xaml_typename(), + viewViewManagerTypeName, + winrt::PropertyMetadata(winrt::box_value(true))); + + return s_canBeScrollAnchorProperty; +} + const wchar_t *ViewViewManager::GetName() const { return L"RCTView"; } @@ -391,6 +409,7 @@ void ViewViewManager::GetNativeProps(const winrt::Microsoft::ReactNative::IJSVal winrt::Microsoft::ReactNative::WriteProperty(writer, L"focusable", L"boolean"); winrt::Microsoft::ReactNative::WriteProperty(writer, L"enableFocusRing", L"boolean"); winrt::Microsoft::ReactNative::WriteProperty(writer, L"tabIndex", L"number"); + winrt::Microsoft::ReactNative::WriteProperty(writer, L"overflowAnchor", L"string"); } bool ViewViewManager::UpdateProperty( @@ -447,6 +466,18 @@ bool ViewViewManager::UpdateProperty( if (resetTabIndex) { pViewShadowNode->TabIndex(std::numeric_limits::max()); } + } else if (propertyName == "overflowAnchor") { +#ifndef USE_WINUI3 + if (propertyValue.Type() == React::JSValueType::String) { + if (propertyValue.AsString() == "none") { + pViewShadowNode->GetView().SetValue(CanBeScrollAnchorProperty(), winrt::box_value(false)); + } else { + pViewShadowNode->GetView().ClearValue(CanBeScrollAnchorProperty()); + } + } else if (propertyValue.IsNull()) { + pViewShadowNode->GetView().ClearValue(CanBeScrollAnchorProperty()); + } +#endif } else { if (propertyName == "accessible") { pViewShadowNode->IsAccessible(propertyValue.AsBoolean()); diff --git a/vnext/Microsoft.ReactNative/Views/ViewViewManager.h b/vnext/Microsoft.ReactNative/Views/ViewViewManager.h index 413474689c9..0eddb81c7e2 100644 --- a/vnext/Microsoft.ReactNative/Views/ViewViewManager.h +++ b/vnext/Microsoft.ReactNative/Views/ViewViewManager.h @@ -14,6 +14,8 @@ class ViewViewManager : public FrameworkElementViewManager { using Super = FrameworkElementViewManager; public: + static xaml::DependencyProperty CanBeScrollAnchorProperty(); + ViewViewManager(const Mso::React::IReactContext &context); const wchar_t *GetName() const override; diff --git a/vnext/src/Libraries/Components/View/ReactNativeViewAttributes.windows.js b/vnext/src/Libraries/Components/View/ReactNativeViewAttributes.windows.js index 36082746090..a45644eaf1d 100644 --- a/vnext/src/Libraries/Components/View/ReactNativeViewAttributes.windows.js +++ b/vnext/src/Libraries/Components/View/ReactNativeViewAttributes.windows.js @@ -38,6 +38,7 @@ const UIView = { // [Windows onMouseEnter: true, onMouseLeave: true, + overflowAnchor: true, // Windows] }; diff --git a/vnext/src/Libraries/Components/View/ViewPropTypes.windows.js b/vnext/src/Libraries/Components/View/ViewPropTypes.windows.js index ad20015e91a..80dee0c4d34 100644 --- a/vnext/src/Libraries/Components/View/ViewPropTypes.windows.js +++ b/vnext/src/Libraries/Components/View/ViewPropTypes.windows.js @@ -477,6 +477,7 @@ type WindowsViewProps = $ReadOnly<{| onBlur?: ?(event: FocusEvent) => mixed, onMouseLeave?: ?(event: MouseEvent) => mixed, onMouseEnter?: ?(event: MouseEvent) => mixed, + overflowAnchor?: 'auto' | 'none', |}>; // Windows] diff --git a/vnext/src/Libraries/Components/View/ViewWindowsProps.ts b/vnext/src/Libraries/Components/View/ViewWindowsProps.ts index 74efc1531be..365ab7b8d05 100644 --- a/vnext/src/Libraries/Components/View/ViewWindowsProps.ts +++ b/vnext/src/Libraries/Components/View/ViewWindowsProps.ts @@ -77,4 +77,9 @@ export interface IViewWindowsProps extends IKeyboardProps, ViewProps { * Event fired when the mouse enters the view */ onMouseEnter?: (args: IMouseEvent) => void; + + /** + * Indicates that view must not be used as scroll anchor candidate. + */ + overflowAnchor?: "none" | "auto"; } From dee7245aaf838dd590c7a2daeda9aca41819bf22 Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Wed, 13 Jul 2022 15:55:48 -0400 Subject: [PATCH 04/11] Do not emit scroll events when ActualWidth and ActualHeight are invalid --- vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp b/vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp index 25f3e7692a3..dae6f088f4f 100644 --- a/vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp +++ b/vnext/Microsoft.ReactNative/Views/ScrollViewManager.cpp @@ -61,6 +61,7 @@ class ScrollViewShadowNode : public ShadowNodeBase { bool m_isInverted = false; bool m_isScrollingEnabled = true; bool m_changeViewAfterLoaded = false; + bool m_isLoaded = false; bool m_dismissKeyboardOnDrag = false; ScrollViewViewChanger m_viewChanger; @@ -342,6 +343,7 @@ void ScrollViewShadowNode::AddHandlers(const winrt::ScrollViewer &scrollViewer) m_isScrollingFromInertia = false; }); m_controlLoadedRevoker = scrollViewer.Loaded(winrt::auto_revoke, [this](const auto &sender, const auto &) { + m_isLoaded = true; if (m_changeViewAfterLoaded) { const auto scrollViewer = sender.as(); scrollViewer.ChangeView(nullptr, nullptr, static_cast(m_zoomFactor)); @@ -358,6 +360,13 @@ void ScrollViewShadowNode::EmitScrollEvent( double y, double zoom, CoalesceType coalesceType) { + // Do not emit scroll events before the ScrollViewer is loaded when in the + // context of an inverted VirtualizedList. Emitting the scroll event when the + // control has not been loaded sends incorrect values for the `ActualWidth` + // and `ActualHeight`, which can mess up the VirtualizedList behavior. + if (m_isInverted && !m_isLoaded) + return; + const auto scrollViewerNotNull = scrollViewer; JSValueObject contentOffset{{"x", x}, {"y", y}}; From b181ffd96e4a6d1326ca1de9780630d586536010 Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Tue, 2 Aug 2022 11:55:17 -0400 Subject: [PATCH 05/11] Simplify content anchoring updates and fix ScrollToEnd behavior --- .../Views/Impl/ScrollViewViewChanger.cpp | 78 +++++++++---------- .../Views/Impl/ScrollViewViewChanger.h | 11 ++- 2 files changed, 46 insertions(+), 43 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp index ea1240c3eee..4d9bd5be4cc 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp @@ -22,17 +22,16 @@ void ScrollViewViewChanger::Inverted(bool inverted) { } void ScrollViewViewChanger::ScrollToEnd(const xaml::Controls::ScrollViewer &scrollViewer, bool animated) { - m_isScrollingToEnd = animated && m_inverted; - if (m_isScrollingToEnd) { - // Disable scroll anchoring while scrolling to end - ScrollViewUWPImplementation(scrollViewer).ContentAnchoringEnabled(false); - SetContentScrollAnchors(scrollViewer, false); + if (m_inverted) { + UpdateScrollAnchoringEnabled(scrollViewer, false); } if (m_horizontal) { - scrollViewer.ChangeView(scrollViewer.ScrollableWidth(), nullptr, nullptr, !animated); + m_targetScrollToEndOffset = scrollViewer.ScrollableWidth(); + scrollViewer.ChangeView(m_targetScrollToEndOffset.value(), nullptr, nullptr, !animated); } else { - scrollViewer.ChangeView(nullptr, scrollViewer.ScrollableHeight(), nullptr, !animated); + m_targetScrollToEndOffset = scrollViewer.ScrollableHeight(); + scrollViewer.ChangeView(nullptr, m_targetScrollToEndOffset.value(), nullptr, !animated); } } @@ -41,23 +40,17 @@ void ScrollViewViewChanger::OnViewChanging( const xaml::Controls::ScrollViewerViewChangingEventArgs &args) { // For non-inverted views, a ScrollViewer.ViewChanging event always emits an `onScroll` event if (m_inverted) { - // For inverted views, we need to detect if we're scrolling to or away from the bottom edge to enable or disable - // view anchoring - const auto scrollingToEnd = - IsScrollingToEnd(scrollViewer, args.NextView().HorizontalOffset(), args.NextView().VerticalOffset()); - - // Do not update scroll anchoring while scrolling to end - if (!m_isScrollingToEnd) { - ScrollViewUWPImplementation(scrollViewer).ContentAnchoringEnabled(!scrollingToEnd); - if (m_wasViewAnchoringEnabled && scrollingToEnd) { - // If scrolling to anchor edge, turn off view anchoring - SetContentScrollAnchors(scrollViewer, false); - m_wasViewAnchoringEnabled = false; - } else if (!m_wasViewAnchoringEnabled && !scrollingToEnd) { - // If scrolling away from anchor edge, turn on view anchoring - SetContentScrollAnchors(scrollViewer, true); - m_wasViewAnchoringEnabled = true; - } + // Do not update scroll anchoring during ScrollToEnd + if (!IsScrollToEndActive()) { + // For inverted views, we need to detect if we're scrolling to or away from the bottom edge to enable or disable + // view anchoring + const auto scrollingToEnd = + IsScrollingToEnd(scrollViewer, args.NextView().HorizontalOffset(), args.NextView().VerticalOffset()); + UpdateScrollAnchoringEnabled(scrollViewer, !scrollingToEnd); + } else if (IsScrollingToEnd(scrollViewer, m_targetScrollToEndOffset.value(), m_targetScrollToEndOffset.value())) { + // If we were previously in an active ScrollToEnd command, we may need to + // restart the operation if the content size has changed + ScrollToEnd(scrollViewer, true); } } } @@ -66,25 +59,32 @@ void ScrollViewViewChanger::OnViewChanged( const xaml::Controls::ScrollViewer &scrollViewer, const xaml::Controls::ScrollViewerViewChangedEventArgs &args) { // Stop tracking scroll-to-end once the ScrollView comes to rest - if (!args.IsIntermediate() && m_isScrollingToEnd) { - m_isScrollingToEnd = false; - if (!IsScrollingToEnd(scrollViewer, scrollViewer.HorizontalOffset(), scrollViewer.VerticalOffset())) { - ScrollViewUWPImplementation(scrollViewer).ContentAnchoringEnabled(true); - SetContentScrollAnchors(scrollViewer, true); + if (!args.IsIntermediate()) { + m_targetScrollToEndOffset = std::nullopt; + if (m_inverted) { + const auto scrolledToEnd = + IsScrollingToEnd(scrollViewer, scrollViewer.HorizontalOffset(), scrollViewer.VerticalOffset()); + UpdateScrollAnchoringEnabled(!scrolledToEnd); } } } -void ScrollViewViewChanger::SetContentScrollAnchors(const xaml::Controls::ScrollViewer &scrollViewer, bool enabled) { - const auto snapPointManager = scrollViewer.Content().as(); - auto panel = snapPointManager->Content().as(); - for (auto child : panel.Children()) { - const auto childElement = child.as(); - if (winrt::unbox_value(childElement.GetValue(ViewViewManager::CanBeScrollAnchorProperty()))) { - if (enabled) { - childElement.CanBeScrollAnchor(true); - } else { - childElement.ClearValue(xaml::UIElement::CanBeScrollAnchorProperty()); +void ScrollViewViewChanger::UpdateScrollAnchoringEnabled( + const xaml::Controls::ScrollViewer &scrollViewer, + bool enabled) { + if (m_wasScrollAnchoringEnabled != enabled) { + m_wasScrollAnchoringEnabled = enabled; + ScrollViewUWPImplementation(scrollViewer).ContentAnchoringEnabled(enabled); + const auto snapPointManager = scrollViewer.Content().as(); + auto panel = snapPointManager->Content().as(); + for (auto child : panel.Children()) { + const auto childElement = child.as(); + if (winrt::unbox_value(childElement.GetValue(ViewViewManager::CanBeScrollAnchorProperty()))) { + if (enabled) { + childElement.CanBeScrollAnchor(true); + } else { + childElement.ClearValue(xaml::UIElement::CanBeScrollAnchorProperty()); + } } } } diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h index f8f1be4e278..e6b29ec5900 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h @@ -22,11 +22,14 @@ class ScrollViewViewChanger { private: bool m_inverted{false}; bool m_horizontal{false}; - bool m_isScrollingToEnd{false}; - bool m_wasViewAnchoringEnabled{false}; - double m_targetScrollToEndOffset{0.0}; + bool m_wasScrollAnchoringEnabled{false}; + std::optional m_targetScrollToEndOffset{std::nullopt}; - static void SetContentScrollAnchors(const xaml::Controls::ScrollViewer &scrollViewer, bool enabled); + inline bool IsScrollToEndActive() { + return m_targetScrollToEndOffset.has_value(); + } + + static void UpdateScrollAnchoringEnabled(const xaml::Controls::ScrollViewer &scrollViewer, bool enabled); bool IsScrollingToEnd(const xaml::Controls::ScrollViewer &scrollViewer, double horizontalOffset, double verticalOffset); }; From fba0d4b21117db6152547b794b33889f453b8f97 Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Tue, 2 Aug 2022 14:37:15 -0400 Subject: [PATCH 06/11] Fix debug overlay for native inversion --- .../Views/Impl/ScrollViewViewChanger.cpp | 4 ++-- .../Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h | 2 +- vnext/src/Libraries/Components/View/ViewWindowsProps.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp index 4d9bd5be4cc..85791376d0d 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp @@ -47,7 +47,7 @@ void ScrollViewViewChanger::OnViewChanging( const auto scrollingToEnd = IsScrollingToEnd(scrollViewer, args.NextView().HorizontalOffset(), args.NextView().VerticalOffset()); UpdateScrollAnchoringEnabled(scrollViewer, !scrollingToEnd); - } else if (IsScrollingToEnd(scrollViewer, m_targetScrollToEndOffset.value(), m_targetScrollToEndOffset.value())) { + } else if (!IsScrollingToEnd(scrollViewer, m_targetScrollToEndOffset.value(), m_targetScrollToEndOffset.value())) { // If we were previously in an active ScrollToEnd command, we may need to // restart the operation if the content size has changed ScrollToEnd(scrollViewer, true); @@ -64,7 +64,7 @@ void ScrollViewViewChanger::OnViewChanged( if (m_inverted) { const auto scrolledToEnd = IsScrollingToEnd(scrollViewer, scrollViewer.HorizontalOffset(), scrollViewer.VerticalOffset()); - UpdateScrollAnchoringEnabled(!scrolledToEnd); + UpdateScrollAnchoringEnabled(scrollViewer, !scrolledToEnd); } } } diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h index e6b29ec5900..8dc8a9f3337 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h @@ -29,7 +29,7 @@ class ScrollViewViewChanger { return m_targetScrollToEndOffset.has_value(); } - static void UpdateScrollAnchoringEnabled(const xaml::Controls::ScrollViewer &scrollViewer, bool enabled); + void UpdateScrollAnchoringEnabled(const xaml::Controls::ScrollViewer &scrollViewer, bool enabled); bool IsScrollingToEnd(const xaml::Controls::ScrollViewer &scrollViewer, double horizontalOffset, double verticalOffset); }; diff --git a/vnext/src/Libraries/Components/View/ViewWindowsProps.ts b/vnext/src/Libraries/Components/View/ViewWindowsProps.ts index 365ab7b8d05..65015ecebe9 100644 --- a/vnext/src/Libraries/Components/View/ViewWindowsProps.ts +++ b/vnext/src/Libraries/Components/View/ViewWindowsProps.ts @@ -81,5 +81,5 @@ export interface IViewWindowsProps extends IKeyboardProps, ViewProps { /** * Indicates that view must not be used as scroll anchor candidate. */ - overflowAnchor?: "none" | "auto"; + overflowAnchor?: 'none' | 'auto'; } From a713bd421a84ce8268c38bfb5c7d2b562df1ef4b Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Tue, 15 Nov 2022 10:26:35 -0500 Subject: [PATCH 07/11] yarn format --- vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp b/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp index b7435316f6f..0c1bebb6dee 100644 --- a/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp +++ b/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp @@ -356,9 +356,7 @@ bool TryUpdateBorderProperties( ViewViewManager::ViewViewManager(const Mso::React::IReactContext &context) : Super(context) {} -const winrt::TypeName viewViewManagerTypeName{ - winrt::hstring{L"ViewViewManager"}, - winrt::TypeKind::Metadata}; +const winrt::TypeName viewViewManagerTypeName{winrt::hstring{L"ViewViewManager"}, winrt::TypeKind::Metadata}; /*static*/ xaml::DependencyProperty ViewViewManager::CanBeScrollAnchorProperty() { static xaml::DependencyProperty s_canBeScrollAnchorProperty = xaml::DependencyProperty::RegisterAttached( From bf8d65c82bc0f1654ba299e5d53fe930c6c5a578 Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Thu, 26 Jan 2023 14:51:31 -0500 Subject: [PATCH 08/11] Adds unmodified JS files that will be forked --- vnext/.flowconfig | 2 + vnext/overrides.json | 14 +- .../Lists/VirtualizedList.windows.js | 1877 +++++++++++++++++ .../Lists/VirtualizedListProps.windows.js | 279 +++ 4 files changed, 2171 insertions(+), 1 deletion(-) create mode 100644 vnext/src/Libraries/Lists/VirtualizedList.windows.js create mode 100644 vnext/src/Libraries/Lists/VirtualizedListProps.windows.js diff --git a/vnext/.flowconfig b/vnext/.flowconfig index e59276cb13a..783f08c02b7 100644 --- a/vnext/.flowconfig +++ b/vnext/.flowconfig @@ -27,6 +27,8 @@ /Libraries/Components/View/View.js /Libraries/DeprecatedPropTypes/DeprecatedViewAccessibility.js /Libraries/Image/Image.js +/Libraries/Lists/VirtualizedList.js +/Libraries/Lists/VirtualizedListProps.js /Libraries/Network/RCTNetworking.js /Libraries/NewAppScreen/components/DebugInstructions.js /Libraries/NewAppScreen/components/ReloadInstructions.js diff --git a/vnext/overrides.json b/vnext/overrides.json index 164c0507d5a..123a9a26d77 100644 --- a/vnext/overrides.json +++ b/vnext/overrides.json @@ -376,6 +376,18 @@ "baseHash": "7d73c2d79bc09080618747146ebbba9b9da7dbca", "issue": 4590 }, + { + "type": "derived", + "file": "src/Libraries/Lists/VirtualizedList.windows.js", + "baseFile": "Libraries/Lists/VirtualizedList.js", + "baseHash": "de1136359cf78fa3f2593388924893c36c526650" + }, + { + "type": "derived", + "file": "src/Libraries/Lists/VirtualizedListProps.windows.js", + "baseFile": "Libraries/Lists/VirtualizedListProps.js", + "baseHash": "de1136359cf78fa3f2593388924893c36c526650" + }, { "type": "derived", "file": "src/Libraries/LogBox/UI/LogBoxInspectorCodeFrame.windows.js", @@ -488,4 +500,4 @@ "baseHash": "a8b6131f20d78db59b5efc33c4f8de86aad527dc" } ] -} \ No newline at end of file +} diff --git a/vnext/src/Libraries/Lists/VirtualizedList.windows.js b/vnext/src/Libraries/Lists/VirtualizedList.windows.js new file mode 100644 index 00000000000..2629142d951 --- /dev/null +++ b/vnext/src/Libraries/Lists/VirtualizedList.windows.js @@ -0,0 +1,1877 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +import type {ScrollResponderType} from '../Components/ScrollView/ScrollView'; +import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; +import type {LayoutEvent, ScrollEvent} from '../Types/CoreEventTypes'; +import type {ViewToken} from './ViewabilityHelper'; +import type { + FrameMetricProps, + Item, + Props, + RenderItemProps, + RenderItemType, + Separators, +} from './VirtualizedListProps'; + +import RefreshControl from '../Components/RefreshControl/RefreshControl'; +import ScrollView from '../Components/ScrollView/ScrollView'; +import View from '../Components/View/View'; +import Batchinator from '../Interaction/Batchinator'; +import {findNodeHandle} from '../ReactNative/RendererProxy'; +import flattenStyle from '../StyleSheet/flattenStyle'; +import StyleSheet from '../StyleSheet/StyleSheet'; +import clamp from '../Utilities/clamp'; +import infoLog from '../Utilities/infoLog'; +import {CellRenderMask} from './CellRenderMask'; +import ChildListCollection from './ChildListCollection'; +import FillRateHelper from './FillRateHelper'; +import StateSafePureComponent from './StateSafePureComponent'; +import ViewabilityHelper from './ViewabilityHelper'; +import CellRenderer from './VirtualizedListCellRenderer'; +import { + VirtualizedListCellContextProvider, + VirtualizedListContext, + VirtualizedListContextProvider, +} from './VirtualizedListContext.js'; +import { + computeWindowedRenderLimits, + keyExtractor as defaultKeyExtractor, +} from './VirtualizeUtils'; +import invariant from 'invariant'; +import * as React from 'react'; + +export type {RenderItemProps, RenderItemType, Separators}; + +const ON_END_REACHED_EPSILON = 0.001; + +let _usedIndexForKey = false; +let _keylessItemComponentName: string = ''; + +type ViewabilityHelperCallbackTuple = { + viewabilityHelper: ViewabilityHelper, + onViewableItemsChanged: (info: { + viewableItems: Array, + changed: Array, + ... + }) => void, + ... +}; + +type State = { + renderMask: CellRenderMask, + cellsAroundViewport: {first: number, last: number}, +}; + +/** + * Default Props Helper Functions + * Use the following helper functions for default values + */ + +// horizontalOrDefault(this.props.horizontal) +function horizontalOrDefault(horizontal: ?boolean) { + return horizontal ?? false; +} + +// initialNumToRenderOrDefault(this.props.initialNumToRenderOrDefault) +function initialNumToRenderOrDefault(initialNumToRender: ?number) { + return initialNumToRender ?? 10; +} + +// maxToRenderPerBatchOrDefault(this.props.maxToRenderPerBatch) +function maxToRenderPerBatchOrDefault(maxToRenderPerBatch: ?number) { + return maxToRenderPerBatch ?? 10; +} + +// onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold) +function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) { + return onEndReachedThreshold ?? 2; +} + +// scrollEventThrottleOrDefault(this.props.scrollEventThrottle) +function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) { + return scrollEventThrottle ?? 50; +} + +// windowSizeOrDefault(this.props.windowSize) +function windowSizeOrDefault(windowSize: ?number) { + return windowSize ?? 21; +} + +function findLastWhere( + arr: $ReadOnlyArray, + predicate: (element: T) => boolean, +): T | null { + for (let i = arr.length - 1; i >= 0; i--) { + if (predicate(arr[i])) { + return arr[i]; + } + } + + return null; +} + +/** + * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) + * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better + * documented. In general, this should only really be used if you need more flexibility than + * `FlatList` provides, e.g. for use with immutable data instead of plain arrays. + * + * Virtualization massively improves memory consumption and performance of large lists by + * maintaining a finite render window of active items and replacing all items outside of the render + * window with appropriately sized blank space. The window adapts to scrolling behavior, and items + * are rendered incrementally with low-pri (after any running interactions) if they are far from the + * visible area, or with hi-pri otherwise to minimize the potential of seeing blank space. + * + * Some caveats: + * + * - Internal state is not preserved when content scrolls out of the render window. Make sure all + * your data is captured in the item data or external stores like Flux, Redux, or Relay. + * - This is a `PureComponent` which means that it will not re-render if `props` remain shallow- + * equal. Make sure that everything your `renderItem` function depends on is passed as a prop + * (e.g. `extraData`) that is not `===` after updates, otherwise your UI may not update on + * changes. This includes the `data` prop and parent component state. + * - In order to constrain memory and enable smooth scrolling, content is rendered asynchronously + * offscreen. This means it's possible to scroll faster than the fill rate ands momentarily see + * blank content. This is a tradeoff that can be adjusted to suit the needs of each application, + * and we are working on improving it behind the scenes. + * - By default, the list looks for a `key` or `id` prop on each item and uses that for the React key. + * Alternatively, you can provide a custom `keyExtractor` prop. + * - As an effort to remove defaultProps, use helper functions when referencing certain props + * + */ +export default class VirtualizedList extends StateSafePureComponent< + Props, + State, +> { + static contextType: typeof VirtualizedListContext = VirtualizedListContext; + + // scrollToEnd may be janky without getItemLayout prop + scrollToEnd(params?: ?{animated?: ?boolean, ...}) { + const animated = params ? params.animated : true; + const veryLast = this.props.getItemCount(this.props.data) - 1; + const frame = this.__getFrameMetricsApprox(veryLast, this.props); + const offset = Math.max( + 0, + frame.offset + + frame.length + + this._footerLength - + this._scrollMetrics.visibleLength, + ); + + if (this._scrollRef == null) { + return; + } + + if (this._scrollRef.scrollTo == null) { + console.warn( + 'No scrollTo method provided. This may be because you have two nested ' + + 'VirtualizedLists with the same orientation, or because you are ' + + 'using a custom component that does not implement scrollTo.', + ); + return; + } + + this._scrollRef.scrollTo( + horizontalOrDefault(this.props.horizontal) + ? {x: offset, animated} + : {y: offset, animated}, + ); + } + + // scrollToIndex may be janky without getItemLayout prop + scrollToIndex(params: { + animated?: ?boolean, + index: number, + viewOffset?: number, + viewPosition?: number, + ... + }): $FlowFixMe { + const { + data, + horizontal, + getItemCount, + getItemLayout, + onScrollToIndexFailed, + } = this.props; + const {animated, index, viewOffset, viewPosition} = params; + invariant( + index >= 0, + `scrollToIndex out of range: requested index ${index} but minimum is 0`, + ); + invariant( + getItemCount(data) >= 1, + `scrollToIndex out of range: item length ${getItemCount( + data, + )} but minimum is 1`, + ); + invariant( + index < getItemCount(data), + `scrollToIndex out of range: requested index ${index} is out of 0 to ${ + getItemCount(data) - 1 + }`, + ); + if (!getItemLayout && index > this._highestMeasuredFrameIndex) { + invariant( + !!onScrollToIndexFailed, + 'scrollToIndex should be used in conjunction with getItemLayout or onScrollToIndexFailed, ' + + 'otherwise there is no way to know the location of offscreen indices or handle failures.', + ); + onScrollToIndexFailed({ + averageItemLength: this._averageCellLength, + highestMeasuredFrameIndex: this._highestMeasuredFrameIndex, + index, + }); + return; + } + const frame = this.__getFrameMetricsApprox(Math.floor(index), this.props); + const offset = + Math.max( + 0, + this._getOffsetApprox(index, this.props) - + (viewPosition || 0) * + (this._scrollMetrics.visibleLength - frame.length), + ) - (viewOffset || 0); + + if (this._scrollRef == null) { + return; + } + + if (this._scrollRef.scrollTo == null) { + console.warn( + 'No scrollTo method provided. This may be because you have two nested ' + + 'VirtualizedLists with the same orientation, or because you are ' + + 'using a custom component that does not implement scrollTo.', + ); + return; + } + + this._scrollRef.scrollTo( + horizontal ? {x: offset, animated} : {y: offset, animated}, + ); + } + + // scrollToItem may be janky without getItemLayout prop. Required linear scan through items - + // use scrollToIndex instead if possible. + scrollToItem(params: { + animated?: ?boolean, + item: Item, + viewOffset?: number, + viewPosition?: number, + ... + }) { + const {item} = params; + const {data, getItem, getItemCount} = this.props; + const itemCount = getItemCount(data); + for (let index = 0; index < itemCount; index++) { + if (getItem(data, index) === item) { + this.scrollToIndex({...params, index}); + break; + } + } + } + + /** + * Scroll to a specific content pixel offset in the list. + * + * Param `offset` expects the offset to scroll to. + * In case of `horizontal` is true, the offset is the x-value, + * in any other case the offset is the y-value. + * + * Param `animated` (`true` by default) defines whether the list + * should do an animation while scrolling. + */ + scrollToOffset(params: {animated?: ?boolean, offset: number, ...}) { + const {animated, offset} = params; + + if (this._scrollRef == null) { + return; + } + + if (this._scrollRef.scrollTo == null) { + console.warn( + 'No scrollTo method provided. This may be because you have two nested ' + + 'VirtualizedLists with the same orientation, or because you are ' + + 'using a custom component that does not implement scrollTo.', + ); + return; + } + + this._scrollRef.scrollTo( + horizontalOrDefault(this.props.horizontal) + ? {x: offset, animated} + : {y: offset, animated}, + ); + } + + recordInteraction() { + this._nestedChildLists.forEach(childList => { + childList.recordInteraction(); + }); + this._viewabilityTuples.forEach(t => { + t.viewabilityHelper.recordInteraction(); + }); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + } + + flashScrollIndicators() { + if (this._scrollRef == null) { + return; + } + + this._scrollRef.flashScrollIndicators(); + } + + /** + * Provides a handle to the underlying scroll responder. + * Note that `this._scrollRef` might not be a `ScrollView`, so we + * need to check that it responds to `getScrollResponder` before calling it. + */ + getScrollResponder(): ?ScrollResponderType { + if (this._scrollRef && this._scrollRef.getScrollResponder) { + return this._scrollRef.getScrollResponder(); + } + } + + getScrollableNode(): ?number { + if (this._scrollRef && this._scrollRef.getScrollableNode) { + return this._scrollRef.getScrollableNode(); + } else { + return findNodeHandle(this._scrollRef); + } + } + + getScrollRef(): + | ?React.ElementRef + | ?React.ElementRef { + if (this._scrollRef && this._scrollRef.getScrollRef) { + return this._scrollRef.getScrollRef(); + } else { + return this._scrollRef; + } + } + + setNativeProps(props: Object) { + if (this._scrollRef) { + this._scrollRef.setNativeProps(props); + } + } + + _getCellKey(): string { + return this.context?.cellKey || 'rootList'; + } + + // $FlowFixMe[missing-local-annot] + _getScrollMetrics = () => { + return this._scrollMetrics; + }; + + hasMore(): boolean { + return this._hasMore; + } + + // $FlowFixMe[missing-local-annot] + _getOutermostParentListRef = () => { + if (this._isNestedWithSameOrientation()) { + return this.context.getOutermostParentListRef(); + } else { + return this; + } + }; + + _registerAsNestedChild = (childList: { + cellKey: string, + ref: React.ElementRef, + }): void => { + this._nestedChildLists.add(childList.ref, childList.cellKey); + if (this._hasInteracted) { + childList.ref.recordInteraction(); + } + }; + + _unregisterAsNestedChild = (childList: { + ref: React.ElementRef, + }): void => { + this._nestedChildLists.remove(childList.ref); + }; + + state: State; + + constructor(props: Props) { + super(props); + invariant( + // $FlowFixMe[prop-missing] + !props.onScroll || !props.onScroll.__isNative, + 'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' + + 'to support native onScroll events with useNativeDriver', + ); + invariant( + windowSizeOrDefault(props.windowSize) > 0, + 'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.', + ); + + invariant( + props.getItemCount, + 'VirtualizedList: The "getItemCount" prop must be provided', + ); + + this._fillRateHelper = new FillRateHelper(this._getFrameMetrics); + this._updateCellsToRenderBatcher = new Batchinator( + this._updateCellsToRender, + this.props.updateCellsBatchingPeriod ?? 50, + ); + + if (this.props.viewabilityConfigCallbackPairs) { + this._viewabilityTuples = this.props.viewabilityConfigCallbackPairs.map( + pair => ({ + viewabilityHelper: new ViewabilityHelper(pair.viewabilityConfig), + onViewableItemsChanged: pair.onViewableItemsChanged, + }), + ); + } else { + const {onViewableItemsChanged, viewabilityConfig} = this.props; + if (onViewableItemsChanged) { + this._viewabilityTuples.push({ + viewabilityHelper: new ViewabilityHelper(viewabilityConfig), + onViewableItemsChanged: onViewableItemsChanged, + }); + } + } + + invariant( + !this.context, + 'Unexpectedly saw VirtualizedListContext available in ctor', + ); + + const initialRenderRegion = VirtualizedList._initialRenderRegion(props); + + this.state = { + cellsAroundViewport: initialRenderRegion, + renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), + }; + } + + static _createRenderMask( + props: Props, + cellsAroundViewport: {first: number, last: number}, + additionalRegions?: ?$ReadOnlyArray<{first: number, last: number}>, + ): CellRenderMask { + const itemCount = props.getItemCount(props.data); + + invariant( + cellsAroundViewport.first >= 0 && + cellsAroundViewport.last >= cellsAroundViewport.first - 1 && + cellsAroundViewport.last < itemCount, + `Invalid cells around viewport "[${cellsAroundViewport.first}, ${cellsAroundViewport.last}]" was passed to VirtualizedList._createRenderMask`, + ); + + const renderMask = new CellRenderMask(itemCount); + + if (itemCount > 0) { + const allRegions = [cellsAroundViewport, ...(additionalRegions ?? [])]; + for (const region of allRegions) { + renderMask.addCells(region); + } + + // The initially rendered cells are retained as part of the + // "scroll-to-top" optimization + if (props.initialScrollIndex == null || props.initialScrollIndex <= 0) { + const initialRegion = VirtualizedList._initialRenderRegion(props); + renderMask.addCells(initialRegion); + } + + // The layout coordinates of sticker headers may be off-screen while the + // actual header is on-screen. Keep the most recent before the viewport + // rendered, even if its layout coordinates are not in viewport. + const stickyIndicesSet = new Set(props.stickyHeaderIndices); + VirtualizedList._ensureClosestStickyHeader( + props, + stickyIndicesSet, + renderMask, + cellsAroundViewport.first, + ); + } + + return renderMask; + } + + static _initialRenderRegion(props: Props): {first: number, last: number} { + const itemCount = props.getItemCount(props.data); + const scrollIndex = Math.floor(Math.max(0, props.initialScrollIndex ?? 0)); + + return { + first: scrollIndex, + last: + Math.min( + itemCount, + scrollIndex + initialNumToRenderOrDefault(props.initialNumToRender), + ) - 1, + }; + } + + static _ensureClosestStickyHeader( + props: Props, + stickyIndicesSet: Set, + renderMask: CellRenderMask, + cellIdx: number, + ) { + const stickyOffset = props.ListHeaderComponent ? 1 : 0; + + for (let itemIdx = cellIdx - 1; itemIdx >= 0; itemIdx--) { + if (stickyIndicesSet.has(itemIdx + stickyOffset)) { + renderMask.addCells({first: itemIdx, last: itemIdx}); + break; + } + } + } + + _adjustCellsAroundViewport( + props: Props, + cellsAroundViewport: {first: number, last: number}, + ): {first: number, last: number} { + const {data, getItemCount} = props; + const onEndReachedThreshold = onEndReachedThresholdOrDefault( + props.onEndReachedThreshold, + ); + this._updateViewableItems(props, cellsAroundViewport); + + const {contentLength, offset, visibleLength} = this._scrollMetrics; + const distanceFromEnd = contentLength - visibleLength - offset; + + // Wait until the scroll view metrics have been set up. And until then, + // we will trust the initialNumToRender suggestion + if (visibleLength <= 0 || contentLength <= 0) { + return cellsAroundViewport.last >= getItemCount(data) + ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) + : cellsAroundViewport; + } + + let newCellsAroundViewport: {first: number, last: number}; + if (props.disableVirtualization) { + const renderAhead = + distanceFromEnd < onEndReachedThreshold * visibleLength + ? maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch) + : 0; + + newCellsAroundViewport = { + first: 0, + last: Math.min( + cellsAroundViewport.last + renderAhead, + getItemCount(data) - 1, + ), + }; + } else { + // If we have a non-zero initialScrollIndex and run this before we've scrolled, + // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. + // So let's wait until we've scrolled the view to the right place. And until then, + // we will trust the initialScrollIndex suggestion. + + // Thus, we want to recalculate the windowed render limits if any of the following hold: + // - initialScrollIndex is undefined or is 0 + // - initialScrollIndex > 0 AND scrolling is complete + // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case + // where the list is shorter than the visible area) + if ( + props.initialScrollIndex && + !this._scrollMetrics.offset && + Math.abs(distanceFromEnd) >= Number.EPSILON + ) { + return cellsAroundViewport.last >= getItemCount(data) + ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) + : cellsAroundViewport; + } + + newCellsAroundViewport = computeWindowedRenderLimits( + props, + maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), + windowSizeOrDefault(props.windowSize), + cellsAroundViewport, + this.__getFrameMetricsApprox, + this._scrollMetrics, + ); + invariant( + newCellsAroundViewport.last < getItemCount(data), + 'computeWindowedRenderLimits() should return range in-bounds', + ); + } + + if (this._nestedChildLists.size() > 0) { + // If some cell in the new state has a child list in it, we should only render + // up through that item, so that we give that list a chance to render. + // Otherwise there's churn from multiple child lists mounting and un-mounting + // their items. + + // Will this prevent rendering if the nested list doesn't realize the end? + const childIdx = this._findFirstChildWithMore( + newCellsAroundViewport.first, + newCellsAroundViewport.last, + ); + + newCellsAroundViewport.last = childIdx ?? newCellsAroundViewport.last; + } + + return newCellsAroundViewport; + } + + _findFirstChildWithMore(first: number, last: number): number | null { + for (let ii = first; ii <= last; ii++) { + const cellKeyForIndex = this._indicesToKeys.get(ii); + if ( + cellKeyForIndex != null && + this._nestedChildLists.anyInCell(cellKeyForIndex, childList => + childList.hasMore(), + ) + ) { + return ii; + } + } + + return null; + } + + componentDidMount() { + if (this._isNestedWithSameOrientation()) { + this.context.registerAsNestedChild({ + ref: this, + cellKey: this.context.cellKey, + }); + } + } + + componentWillUnmount() { + if (this._isNestedWithSameOrientation()) { + this.context.unregisterAsNestedChild({ref: this}); + } + this._updateCellsToRenderBatcher.dispose({abort: true}); + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.dispose(); + }); + this._fillRateHelper.deactivateAndFlush(); + } + + static getDerivedStateFromProps(newProps: Props, prevState: State): State { + // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make + // sure we're rendering a reasonable range here. + const itemCount = newProps.getItemCount(newProps.data); + if (itemCount === prevState.renderMask.numCells()) { + return prevState; + } + + const constrainedCells = VirtualizedList._constrainToItemCount( + prevState.cellsAroundViewport, + newProps, + ); + + return { + cellsAroundViewport: constrainedCells, + renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), + }; + } + + _pushCells( + cells: Array, + stickyHeaderIndices: Array, + stickyIndicesFromProps: Set, + first: number, + last: number, + inversionStyle: ViewStyleProp, + ) { + const { + CellRendererComponent, + ItemSeparatorComponent, + ListHeaderComponent, + ListItemComponent, + data, + debug, + getItem, + getItemCount, + getItemLayout, + horizontal, + renderItem, + } = this.props; + const stickyOffset = ListHeaderComponent ? 1 : 0; + const end = getItemCount(data) - 1; + let prevCellKey; + last = Math.min(end, last); + for (let ii = first; ii <= last; ii++) { + const item = getItem(data, ii); + const key = this._keyExtractor(item, ii, this.props); + this._indicesToKeys.set(ii, key); + if (stickyIndicesFromProps.has(ii + stickyOffset)) { + stickyHeaderIndices.push(cells.length); + } + cells.push( + this._onCellFocusCapture(key)} + onUnmount={this._onCellUnmount} + ref={ref => { + this._cellRefs[key] = ref; + }} + renderItem={renderItem} + />, + ); + prevCellKey = key; + } + } + + static _constrainToItemCount( + cells: {first: number, last: number}, + props: Props, + ): {first: number, last: number} { + const itemCount = props.getItemCount(props.data); + const last = Math.min(itemCount - 1, cells.last); + + const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( + props.maxToRenderPerBatch, + ); + + return { + first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first), + last, + }; + } + + _onUpdateSeparators = (keys: Array, newProps: Object) => { + keys.forEach(key => { + const ref = key != null && this._cellRefs[key]; + ref && ref.updateSeparatorProps(newProps); + }); + }; + + _isNestedWithSameOrientation(): boolean { + const nestedContext = this.context; + return !!( + nestedContext && + !!nestedContext.horizontal === horizontalOrDefault(this.props.horizontal) + ); + } + + _getSpacerKey = (isVertical: boolean): string => + isVertical ? 'height' : 'width'; + + _keyExtractor( + item: Item, + index: number, + props: { + keyExtractor?: ?(item: Item, index: number) => string, + ... + }, + // $FlowFixMe[missing-local-annot] + ) { + if (props.keyExtractor != null) { + return props.keyExtractor(item, index); + } + + const key = defaultKeyExtractor(item, index); + if (key === String(index)) { + _usedIndexForKey = true; + if (item.type && item.type.displayName) { + _keylessItemComponentName = item.type.displayName; + } + } + return key; + } + + render(): React.Node { + if (__DEV__) { + const flatStyles = flattenStyle(this.props.contentContainerStyle); + if (flatStyles != null && flatStyles.flexWrap === 'wrap') { + console.warn( + '`flexWrap: `wrap`` is not supported with the `VirtualizedList` components.' + + 'Consider using `numColumns` with `FlatList` instead.', + ); + } + } + const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} = + this.props; + const {data, horizontal} = this.props; + const inversionStyle = this.props.inverted + ? horizontalOrDefault(this.props.horizontal) + ? styles.horizontallyInverted + : styles.verticallyInverted + : null; + const cells: Array = []; + const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices); + const stickyHeaderIndices = []; + + // 1. Add cell for ListHeaderComponent + if (ListHeaderComponent) { + if (stickyIndicesFromProps.has(0)) { + stickyHeaderIndices.push(0); + } + const element = React.isValidElement(ListHeaderComponent) ? ( + ListHeaderComponent + ) : ( + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + + ); + cells.push( + + + { + // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors + element + } + + , + ); + } + + // 2a. Add a cell for ListEmptyComponent if applicable + const itemCount = this.props.getItemCount(data); + if (itemCount === 0 && ListEmptyComponent) { + const element: React.Element = ((React.isValidElement( + ListEmptyComponent, + ) ? ( + ListEmptyComponent + ) : ( + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + + )): any); + cells.push( + React.cloneElement(element, { + key: '$empty', + onLayout: event => { + this._onLayoutEmpty(event); + if (element.props.onLayout) { + element.props.onLayout(event); + } + }, + style: StyleSheet.compose(inversionStyle, element.props.style), + }), + ); + } + + // 2b. Add cells and spacers for each item + if (itemCount > 0) { + _usedIndexForKey = false; + _keylessItemComponentName = ''; + const spacerKey = this._getSpacerKey(!horizontal); + + const renderRegions = this.state.renderMask.enumerateRegions(); + const lastSpacer = findLastWhere(renderRegions, r => r.isSpacer); + + for (const section of renderRegions) { + if (section.isSpacer) { + // Legacy behavior is to avoid spacers when virtualization is + // disabled (including head spacers on initial render). + if (this.props.disableVirtualization) { + continue; + } + + // Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to + // prevent the user for hyperscrolling into un-measured area because otherwise content will + // likely jump around as it renders in above the viewport. + const isLastSpacer = section === lastSpacer; + const constrainToMeasured = isLastSpacer && !this.props.getItemLayout; + const last = constrainToMeasured + ? clamp( + section.first - 1, + section.last, + this._highestMeasuredFrameIndex, + ) + : section.last; + + const firstMetrics = this.__getFrameMetricsApprox( + section.first, + this.props, + ); + const lastMetrics = this.__getFrameMetricsApprox(last, this.props); + const spacerSize = + lastMetrics.offset + lastMetrics.length - firstMetrics.offset; + cells.push( + , + ); + } else { + this._pushCells( + cells, + stickyHeaderIndices, + stickyIndicesFromProps, + section.first, + section.last, + inversionStyle, + ); + } + } + + if (!this._hasWarned.keys && _usedIndexForKey) { + console.warn( + 'VirtualizedList: missing keys for items, make sure to specify a key or id property on each ' + + 'item or provide a custom keyExtractor.', + _keylessItemComponentName, + ); + this._hasWarned.keys = true; + } + } + + // 3. Add cell for ListFooterComponent + if (ListFooterComponent) { + const element = React.isValidElement(ListFooterComponent) ? ( + ListFooterComponent + ) : ( + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + + ); + cells.push( + + + { + // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors + element + } + + , + ); + } + + // 4. Render the ScrollView + const scrollProps = { + ...this.props, + onContentSizeChange: this._onContentSizeChange, + onLayout: this._onLayout, + onScroll: this._onScroll, + onScrollBeginDrag: this._onScrollBeginDrag, + onScrollEndDrag: this._onScrollEndDrag, + onMomentumScrollBegin: this._onMomentumScrollBegin, + onMomentumScrollEnd: this._onMomentumScrollEnd, + scrollEventThrottle: scrollEventThrottleOrDefault( + this.props.scrollEventThrottle, + ), // TODO: Android support + invertStickyHeaders: + this.props.invertStickyHeaders !== undefined + ? this.props.invertStickyHeaders + : this.props.inverted, + stickyHeaderIndices, + style: inversionStyle + ? [inversionStyle, this.props.style] + : this.props.style, + }; + + this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; + + const innerRet = ( + + {React.cloneElement( + ( + this.props.renderScrollComponent || + this._defaultRenderScrollComponent + )(scrollProps), + { + ref: this._captureScrollRef, + }, + cells, + )} + + ); + let ret: React.Node = innerRet; + if (__DEV__) { + ret = ( + + {scrollContext => { + if ( + scrollContext != null && + !scrollContext.horizontal === + !horizontalOrDefault(this.props.horizontal) && + !this._hasWarned.nesting && + this.context == null && + this.props.scrollEnabled !== false + ) { + // TODO (T46547044): use React.warn once 16.9 is sync'd: https://github.com/facebook/react/pull/15170 + console.error( + 'VirtualizedLists should never be nested inside plain ScrollViews with the same ' + + 'orientation because it can break windowing and other functionality - use another ' + + 'VirtualizedList-backed container instead.', + ); + this._hasWarned.nesting = true; + } + return innerRet; + }} + + ); + } + if (this.props.debug) { + return ( + + {ret} + {this._renderDebugOverlay()} + + ); + } else { + return ret; + } + } + + componentDidUpdate(prevProps: Props) { + const {data, extraData} = this.props; + if (data !== prevProps.data || extraData !== prevProps.extraData) { + // clear the viewableIndices cache to also trigger + // the onViewableItemsChanged callback with the new data + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.resetViewableIndices(); + }); + } + // The `this._hiPriInProgress` is guaranteeing a hiPri cell update will only happen + // once per fiber update. The `_scheduleCellsToRenderUpdate` will set it to true + // if a hiPri update needs to perform. If `componentDidUpdate` is triggered with + // `this._hiPriInProgress=true`, means it's triggered by the hiPri update. The + // `_scheduleCellsToRenderUpdate` will check this condition and not perform + // another hiPri update. + const hiPriInProgress = this._hiPriInProgress; + this._scheduleCellsToRenderUpdate(); + // Make sure setting `this._hiPriInProgress` back to false after `componentDidUpdate` + // is triggered with `this._hiPriInProgress = true` + if (hiPriInProgress) { + this._hiPriInProgress = false; + } + } + + _averageCellLength = 0; + _cellRefs: {[string]: null | CellRenderer} = {}; + _fillRateHelper: FillRateHelper; + _frames: { + [string]: { + inLayout?: boolean, + index: number, + length: number, + offset: number, + }, + } = {}; + _footerLength = 0; + // Used for preventing scrollToIndex from being called multiple times for initialScrollIndex + _hasTriggeredInitialScrollToIndex = false; + _hasInteracted = false; + _hasMore = false; + _hasWarned: {[string]: boolean} = {}; + _headerLength = 0; + _hiPriInProgress: boolean = false; // flag to prevent infinite hiPri cell limit update + _highestMeasuredFrameIndex = 0; + _indicesToKeys: Map = new Map(); + _lastFocusedCellKey: ?string = null; + _nestedChildLists: ChildListCollection = + new ChildListCollection(); + _offsetFromParentVirtualizedList: number = 0; + _prevParentOffset: number = 0; + // $FlowFixMe[missing-local-annot] + _scrollMetrics = { + contentLength: 0, + dOffset: 0, + dt: 10, + offset: 0, + timestamp: 0, + velocity: 0, + visibleLength: 0, + zoomScale: 1, + }; + _scrollRef: ?React.ElementRef = null; + _sentEndForContentLength = 0; + _totalCellLength = 0; + _totalCellsMeasured = 0; + _updateCellsToRenderBatcher: Batchinator; + _viewabilityTuples: Array = []; + + /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's + * LTI update could not be added via codemod */ + _captureScrollRef = ref => { + this._scrollRef = ref; + }; + + _computeBlankness() { + this._fillRateHelper.computeBlankness( + this.props, + this.state.cellsAroundViewport, + this._scrollMetrics, + ); + } + + /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's + * LTI update could not be added via codemod */ + _defaultRenderScrollComponent = props => { + const onRefresh = props.onRefresh; + if (this._isNestedWithSameOrientation()) { + // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors + return ; + } else if (onRefresh) { + invariant( + typeof props.refreshing === 'boolean', + '`refreshing` prop must be set as a boolean in order to use `onRefresh`, but got `' + + JSON.stringify(props.refreshing ?? 'undefined') + + '`', + ); + return ( + // $FlowFixMe[prop-missing] Invalid prop usage + // $FlowFixMe[incompatible-use] + + ) : ( + props.refreshControl + ) + } + /> + ); + } else { + // $FlowFixMe[prop-missing] Invalid prop usage + // $FlowFixMe[incompatible-use] + return ; + } + }; + + _onCellLayout = (e: LayoutEvent, cellKey: string, index: number): void => { + const layout = e.nativeEvent.layout; + const next = { + offset: this._selectOffset(layout), + length: this._selectLength(layout), + index, + inLayout: true, + }; + const curr = this._frames[cellKey]; + if ( + !curr || + next.offset !== curr.offset || + next.length !== curr.length || + index !== curr.index + ) { + this._totalCellLength += next.length - (curr ? curr.length : 0); + this._totalCellsMeasured += curr ? 0 : 1; + this._averageCellLength = + this._totalCellLength / this._totalCellsMeasured; + this._frames[cellKey] = next; + this._highestMeasuredFrameIndex = Math.max( + this._highestMeasuredFrameIndex, + index, + ); + this._scheduleCellsToRenderUpdate(); + } else { + this._frames[cellKey].inLayout = true; + } + + this._triggerRemeasureForChildListsInCell(cellKey); + + this._computeBlankness(); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + }; + + _onCellFocusCapture(cellKey: string) { + this._lastFocusedCellKey = cellKey; + const renderMask = VirtualizedList._createRenderMask( + this.props, + this.state.cellsAroundViewport, + this._getNonViewportRenderRegions(this.props), + ); + + this.setState(state => { + if (!renderMask.equals(state.renderMask)) { + return {renderMask}; + } + return null; + }); + } + + _onCellUnmount = (cellKey: string) => { + const curr = this._frames[cellKey]; + if (curr) { + this._frames[cellKey] = {...curr, inLayout: false}; + } + }; + + _triggerRemeasureForChildListsInCell(cellKey: string): void { + this._nestedChildLists.forEachInCell(cellKey, childList => { + childList.measureLayoutRelativeToContainingList(); + }); + } + + measureLayoutRelativeToContainingList(): void { + // TODO (T35574538): findNodeHandle sometimes crashes with "Unable to find + // node on an unmounted component" during scrolling + try { + if (!this._scrollRef) { + return; + } + // We are assuming that getOutermostParentListRef().getScrollRef() + // is a non-null reference to a ScrollView + this._scrollRef.measureLayout( + this.context.getOutermostParentListRef().getScrollRef(), + (x, y, width, height) => { + this._offsetFromParentVirtualizedList = this._selectOffset({x, y}); + this._scrollMetrics.contentLength = this._selectLength({ + width, + height, + }); + const scrollMetrics = this._convertParentScrollMetrics( + this.context.getScrollMetrics(), + ); + + const metricsChanged = + this._scrollMetrics.visibleLength !== scrollMetrics.visibleLength || + this._scrollMetrics.offset !== scrollMetrics.offset; + + if (metricsChanged) { + this._scrollMetrics.visibleLength = scrollMetrics.visibleLength; + this._scrollMetrics.offset = scrollMetrics.offset; + + // If metrics of the scrollView changed, then we triggered remeasure for child list + // to ensure VirtualizedList has the right information. + this._nestedChildLists.forEach(childList => { + childList.measureLayoutRelativeToContainingList(); + }); + } + }, + error => { + console.warn( + "VirtualizedList: Encountered an error while measuring a list's" + + ' offset from its containing VirtualizedList.', + ); + }, + ); + } catch (error) { + console.warn( + 'measureLayoutRelativeToContainingList threw an error', + error.stack, + ); + } + } + + _onLayout = (e: LayoutEvent) => { + if (this._isNestedWithSameOrientation()) { + // Need to adjust our scroll metrics to be relative to our containing + // VirtualizedList before we can make claims about list item viewability + this.measureLayoutRelativeToContainingList(); + } else { + this._scrollMetrics.visibleLength = this._selectLength( + e.nativeEvent.layout, + ); + } + this.props.onLayout && this.props.onLayout(e); + this._scheduleCellsToRenderUpdate(); + this._maybeCallOnEndReached(); + }; + + _onLayoutEmpty = (e: LayoutEvent) => { + this.props.onLayout && this.props.onLayout(e); + }; + + _getFooterCellKey(): string { + return this._getCellKey() + '-footer'; + } + + _onLayoutFooter = (e: LayoutEvent) => { + this._triggerRemeasureForChildListsInCell(this._getFooterCellKey()); + this._footerLength = this._selectLength(e.nativeEvent.layout); + }; + + _onLayoutHeader = (e: LayoutEvent) => { + this._headerLength = this._selectLength(e.nativeEvent.layout); + }; + + // $FlowFixMe[missing-local-annot] + _renderDebugOverlay() { + const normalize = + this._scrollMetrics.visibleLength / + (this._scrollMetrics.contentLength || 1); + const framesInLayout = []; + const itemCount = this.props.getItemCount(this.props.data); + for (let ii = 0; ii < itemCount; ii++) { + const frame = this.__getFrameMetricsApprox(ii, this.props); + /* $FlowFixMe[prop-missing] (>=0.68.0 site=react_native_fb) This comment + * suppresses an error found when Flow v0.68 was deployed. To see the + * error delete this comment and run Flow. */ + if (frame.inLayout) { + framesInLayout.push(frame); + } + } + const windowTop = this.__getFrameMetricsApprox( + this.state.cellsAroundViewport.first, + this.props, + ).offset; + const frameLast = this.__getFrameMetricsApprox( + this.state.cellsAroundViewport.last, + this.props, + ); + const windowLen = frameLast.offset + frameLast.length - windowTop; + const visTop = this._scrollMetrics.offset; + const visLen = this._scrollMetrics.visibleLength; + + return ( + + {framesInLayout.map((f, ii) => ( + + ))} + + + + ); + } + + _selectLength( + metrics: $ReadOnly<{ + height: number, + width: number, + ... + }>, + ): number { + return !horizontalOrDefault(this.props.horizontal) + ? metrics.height + : metrics.width; + } + + _selectOffset( + metrics: $ReadOnly<{ + x: number, + y: number, + ... + }>, + ): number { + return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x; + } + + _maybeCallOnEndReached() { + const {data, getItemCount, onEndReached, onEndReachedThreshold} = + this.props; + const {contentLength, visibleLength, offset} = this._scrollMetrics; + let distanceFromEnd = contentLength - visibleLength - offset; + + // Especially when oERT is zero it's necessary to 'floor' very small distanceFromEnd values to be 0 + // since debouncing causes us to not fire this event for every single "pixel" we scroll and can thus + // be at the "end" of the list with a distanceFromEnd approximating 0 but not quite there. + if (distanceFromEnd < ON_END_REACHED_EPSILON) { + distanceFromEnd = 0; + } + + // TODO: T121172172 Look into why we're "defaulting" to a threshold of 2 when oERT is not present + const threshold = + onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; + if ( + onEndReached && + this.state.cellsAroundViewport.last === getItemCount(data) - 1 && + distanceFromEnd <= threshold && + this._scrollMetrics.contentLength !== this._sentEndForContentLength + ) { + // Only call onEndReached once for a given content length + this._sentEndForContentLength = this._scrollMetrics.contentLength; + onEndReached({distanceFromEnd}); + } else if (distanceFromEnd > threshold) { + // If the user scrolls away from the end and back again cause + // an onEndReached to be triggered again + this._sentEndForContentLength = 0; + } + } + + _onContentSizeChange = (width: number, height: number) => { + if ( + width > 0 && + height > 0 && + this.props.initialScrollIndex != null && + this.props.initialScrollIndex > 0 && + !this._hasTriggeredInitialScrollToIndex + ) { + if (this.props.contentOffset == null) { + this.scrollToIndex({ + animated: false, + index: this.props.initialScrollIndex, + }); + } + this._hasTriggeredInitialScrollToIndex = true; + } + if (this.props.onContentSizeChange) { + this.props.onContentSizeChange(width, height); + } + this._scrollMetrics.contentLength = this._selectLength({height, width}); + this._scheduleCellsToRenderUpdate(); + this._maybeCallOnEndReached(); + }; + + /* Translates metrics from a scroll event in a parent VirtualizedList into + * coordinates relative to the child list. + */ + _convertParentScrollMetrics = (metrics: { + visibleLength: number, + offset: number, + ... + }): $FlowFixMe => { + // Offset of the top of the nested list relative to the top of its parent's viewport + const offset = metrics.offset - this._offsetFromParentVirtualizedList; + // Child's visible length is the same as its parent's + const visibleLength = metrics.visibleLength; + const dOffset = offset - this._scrollMetrics.offset; + const contentLength = this._scrollMetrics.contentLength; + + return { + visibleLength, + contentLength, + offset, + dOffset, + }; + }; + + _onScroll = (e: Object) => { + this._nestedChildLists.forEach(childList => { + childList._onScroll(e); + }); + if (this.props.onScroll) { + this.props.onScroll(e); + } + const timestamp = e.timeStamp; + let visibleLength = this._selectLength(e.nativeEvent.layoutMeasurement); + let contentLength = this._selectLength(e.nativeEvent.contentSize); + let offset = this._selectOffset(e.nativeEvent.contentOffset); + let dOffset = offset - this._scrollMetrics.offset; + + if (this._isNestedWithSameOrientation()) { + if (this._scrollMetrics.contentLength === 0) { + // Ignore scroll events until onLayout has been called and we + // know our offset from our offset from our parent + return; + } + ({visibleLength, contentLength, offset, dOffset} = + this._convertParentScrollMetrics({ + visibleLength, + offset, + })); + } + + const dt = this._scrollMetrics.timestamp + ? Math.max(1, timestamp - this._scrollMetrics.timestamp) + : 1; + const velocity = dOffset / dt; + + if ( + dt > 500 && + this._scrollMetrics.dt > 500 && + contentLength > 5 * visibleLength && + !this._hasWarned.perf + ) { + infoLog( + 'VirtualizedList: You have a large list that is slow to update - make sure your ' + + 'renderItem function renders components that follow React performance best practices ' + + 'like PureComponent, shouldComponentUpdate, etc.', + {dt, prevDt: this._scrollMetrics.dt, contentLength}, + ); + this._hasWarned.perf = true; + } + + // For invalid negative values (w/ RTL), set this to 1. + const zoomScale = e.nativeEvent.zoomScale < 0 ? 1 : e.nativeEvent.zoomScale; + this._scrollMetrics = { + contentLength, + dt, + dOffset, + offset, + timestamp, + velocity, + visibleLength, + zoomScale, + }; + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + if (!this.props) { + return; + } + this._maybeCallOnEndReached(); + if (velocity !== 0) { + this._fillRateHelper.activate(); + } + this._computeBlankness(); + this._scheduleCellsToRenderUpdate(); + }; + + _scheduleCellsToRenderUpdate() { + const {first, last} = this.state.cellsAroundViewport; + const {offset, visibleLength, velocity} = this._scrollMetrics; + const itemCount = this.props.getItemCount(this.props.data); + let hiPri = false; + const onEndReachedThreshold = onEndReachedThresholdOrDefault( + this.props.onEndReachedThreshold, + ); + const scrollingThreshold = (onEndReachedThreshold * visibleLength) / 2; + // Mark as high priority if we're close to the start of the first item + // But only if there are items before the first rendered item + if (first > 0) { + const distTop = + offset - this.__getFrameMetricsApprox(first, this.props).offset; + hiPri = + hiPri || distTop < 0 || (velocity < -2 && distTop < scrollingThreshold); + } + // Mark as high priority if we're close to the end of the last item + // But only if there are items after the last rendered item + if (last >= 0 && last < itemCount - 1) { + const distBottom = + this.__getFrameMetricsApprox(last, this.props).offset - + (offset + visibleLength); + hiPri = + hiPri || + distBottom < 0 || + (velocity > 2 && distBottom < scrollingThreshold); + } + // Only trigger high-priority updates if we've actually rendered cells, + // and with that size estimate, accurately compute how many cells we should render. + // Otherwise, it would just render as many cells as it can (of zero dimension), + // each time through attempting to render more (limited by maxToRenderPerBatch), + // starving the renderer from actually laying out the objects and computing _averageCellLength. + // If this is triggered in an `componentDidUpdate` followed by a hiPri cellToRenderUpdate + // We shouldn't do another hipri cellToRenderUpdate + if ( + hiPri && + (this._averageCellLength || this.props.getItemLayout) && + !this._hiPriInProgress + ) { + this._hiPriInProgress = true; + // Don't worry about interactions when scrolling quickly; focus on filling content as fast + // as possible. + this._updateCellsToRenderBatcher.dispose({abort: true}); + this._updateCellsToRender(); + return; + } else { + this._updateCellsToRenderBatcher.schedule(); + } + } + + _onScrollBeginDrag = (e: ScrollEvent): void => { + this._nestedChildLists.forEach(childList => { + childList._onScrollBeginDrag(e); + }); + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.recordInteraction(); + }); + this._hasInteracted = true; + this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e); + }; + + _onScrollEndDrag = (e: ScrollEvent): void => { + this._nestedChildLists.forEach(childList => { + childList._onScrollEndDrag(e); + }); + const {velocity} = e.nativeEvent; + if (velocity) { + this._scrollMetrics.velocity = this._selectOffset(velocity); + } + this._computeBlankness(); + this.props.onScrollEndDrag && this.props.onScrollEndDrag(e); + }; + + _onMomentumScrollBegin = (e: ScrollEvent): void => { + this._nestedChildLists.forEach(childList => { + childList._onMomentumScrollBegin(e); + }); + this.props.onMomentumScrollBegin && this.props.onMomentumScrollBegin(e); + }; + + _onMomentumScrollEnd = (e: ScrollEvent): void => { + this._nestedChildLists.forEach(childList => { + childList._onMomentumScrollEnd(e); + }); + this._scrollMetrics.velocity = 0; + this._computeBlankness(); + this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(e); + }; + + _updateCellsToRender = () => { + this.setState((state, props) => { + const cellsAroundViewport = this._adjustCellsAroundViewport( + props, + state.cellsAroundViewport, + ); + const renderMask = VirtualizedList._createRenderMask( + props, + cellsAroundViewport, + this._getNonViewportRenderRegions(props), + ); + + if ( + cellsAroundViewport.first === state.cellsAroundViewport.first && + cellsAroundViewport.last === state.cellsAroundViewport.last && + renderMask.equals(state.renderMask) + ) { + return null; + } + + return {cellsAroundViewport, renderMask}; + }); + }; + + _createViewToken = ( + index: number, + isViewable: boolean, + props: FrameMetricProps, + // $FlowFixMe[missing-local-annot] + ) => { + const {data, getItem} = props; + const item = getItem(data, index); + return { + index, + item, + key: this._keyExtractor(item, index, props), + isViewable, + }; + }; + + /** + * Gets an approximate offset to an item at a given index. Supports + * fractional indices. + */ + _getOffsetApprox = (index: number, props: FrameMetricProps): number => { + if (Number.isInteger(index)) { + return this.__getFrameMetricsApprox(index, props).offset; + } else { + const frameMetrics = this.__getFrameMetricsApprox( + Math.floor(index), + props, + ); + const remainder = index - Math.floor(index); + return frameMetrics.offset + remainder * frameMetrics.length; + } + }; + + __getFrameMetricsApprox: ( + index: number, + props: FrameMetricProps, + ) => { + length: number, + offset: number, + ... + } = (index, props) => { + const frame = this._getFrameMetrics(index, props); + if (frame && frame.index === index) { + // check for invalid frames due to row re-ordering + return frame; + } else { + const {data, getItemCount, getItemLayout} = props; + invariant( + index >= 0 && index < getItemCount(data), + 'Tried to get frame for out of range index ' + index, + ); + invariant( + !getItemLayout, + 'Should not have to estimate frames when a measurement metrics function is provided', + ); + return { + length: this._averageCellLength, + offset: this._averageCellLength * index, + }; + } + }; + + _getFrameMetrics = ( + index: number, + props: FrameMetricProps, + ): ?{ + length: number, + offset: number, + index: number, + inLayout?: boolean, + ... + } => { + const {data, getItem, getItemCount, getItemLayout} = props; + invariant( + index >= 0 && index < getItemCount(data), + 'Tried to get frame for out of range index ' + index, + ); + const item = getItem(data, index); + const frame = item && this._frames[this._keyExtractor(item, index, props)]; + if (!frame || frame.index !== index) { + if (getItemLayout) { + /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment + * suppresses an error found when Flow v0.63 was deployed. To see the error + * delete this comment and run Flow. */ + return getItemLayout(data, index); + } + } + return frame; + }; + + _getNonViewportRenderRegions = ( + props: FrameMetricProps, + ): $ReadOnlyArray<{ + first: number, + last: number, + }> => { + // Keep a viewport's worth of content around the last focused cell to allow + // random navigation around it without any blanking. E.g. tabbing from one + // focused item out of viewport to another. + if ( + !(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey]) + ) { + return []; + } + + const lastFocusedCellRenderer = this._cellRefs[this._lastFocusedCellKey]; + const focusedCellIndex = lastFocusedCellRenderer.props.index; + const itemCount = props.getItemCount(props.data); + + // The cell may have been unmounted and have a stale index + if ( + focusedCellIndex >= itemCount || + this._indicesToKeys.get(focusedCellIndex) !== this._lastFocusedCellKey + ) { + return []; + } + + let first = focusedCellIndex; + let heightOfCellsBeforeFocused = 0; + for ( + let i = first - 1; + i >= 0 && heightOfCellsBeforeFocused < this._scrollMetrics.visibleLength; + i-- + ) { + first--; + heightOfCellsBeforeFocused += this.__getFrameMetricsApprox( + i, + props, + ).length; + } + + let last = focusedCellIndex; + let heightOfCellsAfterFocused = 0; + for ( + let i = last + 1; + i < itemCount && + heightOfCellsAfterFocused < this._scrollMetrics.visibleLength; + i++ + ) { + last++; + heightOfCellsAfterFocused += this.__getFrameMetricsApprox( + i, + props, + ).length; + } + + return [{first, last}]; + }; + + _updateViewableItems( + props: FrameMetricProps, + cellsAroundViewport: {first: number, last: number}, + ) { + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.onUpdate( + props, + this._scrollMetrics.offset, + this._scrollMetrics.visibleLength, + this._getFrameMetrics, + this._createViewToken, + tuple.onViewableItemsChanged, + cellsAroundViewport, + ); + }); + } +} + +const styles = StyleSheet.create({ + verticallyInverted: { + transform: [{scaleY: -1}], + }, + horizontallyInverted: { + transform: [{scaleX: -1}], + }, + debug: { + flex: 1, + }, + debugOverlayBase: { + position: 'absolute', + top: 0, + right: 0, + }, + debugOverlay: { + bottom: 0, + width: 20, + borderColor: 'blue', + borderWidth: 1, + }, + debugOverlayFrame: { + left: 0, + backgroundColor: 'orange', + }, + debugOverlayFrameLast: { + left: 0, + borderColor: 'green', + borderWidth: 2, + }, + debugOverlayFrameVis: { + left: 0, + borderColor: 'red', + borderWidth: 2, + }, +}); diff --git a/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js b/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js new file mode 100644 index 00000000000..62307e471a1 --- /dev/null +++ b/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js @@ -0,0 +1,279 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + + import typeof ScrollView from '../Components/ScrollView/ScrollView'; + import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; + import type { + ViewabilityConfig, + ViewabilityConfigCallbackPair, + ViewToken, + } from './ViewabilityHelper'; + + import * as React from 'react'; + + export type Item = any; + + export type Separators = { + highlight: () => void, + unhighlight: () => void, + updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, + ... + }; + + export type RenderItemProps = { + item: ItemT, + index: number, + separators: Separators, + ... + }; + + export type RenderItemType = ( + info: RenderItemProps, + ) => React.Node; + + type RequiredProps = {| + /** + * The default accessor functions assume this is an Array<{key: string} | {id: string}> but you can override + * getItem, getItemCount, and keyExtractor to handle any type of index-based data. + */ + data?: any, + /** + * A generic accessor for extracting an item from any sort of data blob. + */ + getItem: (data: any, index: number) => ?Item, + /** + * Determines how many items are in the data blob. + */ + getItemCount: (data: any) => number, + |}; + type OptionalProps = {| + renderItem?: ?RenderItemType, + /** + * `debug` will turn on extra logging and visual overlays to aid with debugging both usage and + * implementation, but with a significant perf hit. + */ + debug?: ?boolean, + /** + * DEPRECATED: Virtualization provides significant performance and memory optimizations, but fully + * unmounts react instances that are outside of the render window. You should only need to disable + * this for debugging purposes. Defaults to false. + */ + disableVirtualization?: ?boolean, + /** + * A marker property for telling the list to re-render (since it implements `PureComponent`). If + * any of your `renderItem`, Header, Footer, etc. functions depend on anything outside of the + * `data` prop, stick it here and treat it immutably. + */ + extraData?: any, + // e.g. height, y + getItemLayout?: ( + data: any, + index: number, + ) => { + length: number, + offset: number, + index: number, + ... + }, + horizontal?: ?boolean, + /** + * How many items to render in the initial batch. This should be enough to fill the screen but not + * much more. Note these items will never be unmounted as part of the windowed rendering in order + * to improve perceived performance of scroll-to-top actions. + */ + initialNumToRender?: ?number, + /** + * Instead of starting at the top with the first item, start at `initialScrollIndex`. This + * disables the "scroll to top" optimization that keeps the first `initialNumToRender` items + * always rendered and immediately renders the items starting at this initial index. Requires + * `getItemLayout` to be implemented. + */ + initialScrollIndex?: ?number, + /** + * Reverses the direction of scroll. Uses scale transforms of -1. + */ + inverted?: ?boolean, + keyExtractor?: ?(item: Item, index: number) => string, + /** + * Each cell is rendered using this element. Can be a React Component Class, + * or a render function. Defaults to using View. + */ + CellRendererComponent?: ?React.ComponentType, + /** + * Rendered in between each item, but not at the top or bottom. By default, `highlighted` and + * `leadingItem` props are provided. `renderItem` provides `separators.highlight`/`unhighlight` + * which will update the `highlighted` prop, but you can also add custom props with + * `separators.updateProps`. + */ + ItemSeparatorComponent?: ?React.ComponentType, + /** + * Takes an item from `data` and renders it into the list. Example usage: + * + * ( + * + * )} + * data={[{title: 'Title Text', key: 'item1'}]} + * ListItemComponent={({item, separators}) => ( + * this._onPress(item)} + * onShowUnderlay={separators.highlight} + * onHideUnderlay={separators.unhighlight}> + * + * {item.title} + * + * + * )} + * /> + * + * Provides additional metadata like `index` if you need it, as well as a more generic + * `separators.updateProps` function which let's you set whatever props you want to change the + * rendering of either the leading separator or trailing separator in case the more common + * `highlight` and `unhighlight` (which set the `highlighted: boolean` prop) are insufficient for + * your use-case. + */ + ListItemComponent?: ?(React.ComponentType | React.Element), + /** + * Rendered when the list is empty. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListEmptyComponent?: ?(React.ComponentType | React.Element), + /** + * Rendered at the bottom of all the items. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListFooterComponent?: ?(React.ComponentType | React.Element), + /** + * Styling for internal View for ListFooterComponent + */ + ListFooterComponentStyle?: ViewStyleProp, + /** + * Rendered at the top of all the items. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListHeaderComponent?: ?(React.ComponentType | React.Element), + /** + * Styling for internal View for ListHeaderComponent + */ + ListHeaderComponentStyle?: ViewStyleProp, + /** + * The maximum number of items to render in each incremental render batch. The more rendered at + * once, the better the fill rate, but responsiveness may suffer because rendering content may + * interfere with responding to button taps or other interactions. + */ + maxToRenderPerBatch?: ?number, + /** + * Called once when the scroll position gets within `onEndReachedThreshold` of the rendered + * content. + */ + onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void, + /** + * How far from the end (in units of visible length of the list) the bottom edge of the + * list must be from the end of the content to trigger the `onEndReached` callback. + * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is + * within half the visible length of the list. A value of 0 will not trigger until scrolling + * to the very end of the list. + */ + onEndReachedThreshold?: ?number, + /** + * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make + * sure to also set the `refreshing` prop correctly. + */ + onRefresh?: ?() => void, + /** + * Used to handle failures when scrolling to an index that has not been measured yet. Recommended + * action is to either compute your own offset and `scrollTo` it, or scroll as far as possible and + * then try again after more items have been rendered. + */ + onScrollToIndexFailed?: ?(info: { + index: number, + highestMeasuredFrameIndex: number, + averageItemLength: number, + ... + }) => void, + /** + * Called when the viewability of rows changes, as defined by the + * `viewabilityConfig` prop. + */ + onViewableItemsChanged?: ?(info: { + viewableItems: Array, + changed: Array, + ... + }) => void, + persistentScrollbar?: ?boolean, + /** + * Set this when offset is needed for the loading indicator to show correctly. + */ + progressViewOffset?: number, + /** + * A custom refresh control element. When set, it overrides the default + * component built internally. The onRefresh and refreshing + * props are also ignored. Only works for vertical VirtualizedList. + */ + refreshControl?: ?React.Element, + /** + * Set this true while waiting for new data from a refresh. + */ + refreshing?: ?boolean, + /** + * Note: may have bugs (missing content) in some circumstances - use at your own risk. + * + * This may improve scroll performance for large lists. + */ + removeClippedSubviews?: boolean, + /** + * Render a custom scroll component, e.g. with a differently styled `RefreshControl`. + */ + renderScrollComponent?: (props: Object) => React.Element, + /** + * Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off + * screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`. + */ + updateCellsBatchingPeriod?: ?number, + /** + * See `ViewabilityHelper` for flow type and further documentation. + */ + viewabilityConfig?: ViewabilityConfig, + /** + * List of ViewabilityConfig/onViewableItemsChanged pairs. A specific onViewableItemsChanged + * will be called when its corresponding ViewabilityConfig's conditions are met. + */ + viewabilityConfigCallbackPairs?: Array, + /** + * Determines the maximum number of items rendered outside of the visible area, in units of + * visible lengths. So if your list fills the screen, then `windowSize={21}` (the default) will + * render the visible screen area plus up to 10 screens above and 10 below the viewport. Reducing + * this number will reduce memory consumption and may improve performance, but will increase the + * chance that fast scrolling may reveal momentary blank areas of unrendered content. + */ + windowSize?: ?number, + /** + * The legacy implementation is no longer supported. + */ + legacyImplementation?: empty, + |}; + + export type Props = {| + ...React.ElementConfig, + ...RequiredProps, + ...OptionalProps, + |}; + + /** + * Subset of properties needed to calculate frame metrics + */ + export type FrameMetricProps = { + data: RequiredProps['data'], + getItemCount: RequiredProps['getItemCount'], + getItem: RequiredProps['getItem'], + getItemLayout?: OptionalProps['getItemLayout'], + keyExtractor?: OptionalProps['keyExtractor'], + ... + }; From a9075806528fbb7cbd64c90436820caa79066b52 Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Thu, 26 Jan 2023 15:16:05 -0500 Subject: [PATCH 09/11] Changes to VirtualizedList.js to support native inversion --- .../Lists/VirtualizedList.windows.js | 110 ++++++++++++++---- .../Lists/VirtualizedListProps.windows.js | 1 + 2 files changed, 91 insertions(+), 20 deletions(-) diff --git a/vnext/src/Libraries/Lists/VirtualizedList.windows.js b/vnext/src/Libraries/Lists/VirtualizedList.windows.js index 2629142d951..82960a68a0a 100644 --- a/vnext/src/Libraries/Lists/VirtualizedList.windows.js +++ b/vnext/src/Libraries/Lists/VirtualizedList.windows.js @@ -157,7 +157,7 @@ export default class VirtualizedList extends StateSafePureComponent< scrollToEnd(params?: ?{animated?: ?boolean, ...}) { const animated = params ? params.animated : true; const veryLast = this.props.getItemCount(this.props.data) - 1; - const frame = this.__getFrameMetricsApprox(veryLast, this.props); + const frame = this.__getFrameMetricsApprox(veryLast, this.props, /* useRawMetrics: */ true); const offset = Math.max( 0, frame.offset + @@ -369,8 +369,27 @@ export default class VirtualizedList extends StateSafePureComponent< } // $FlowFixMe[missing-local-annot] - _getScrollMetrics = () => { - return this._scrollMetrics; + _getScrollMetrics = (inverted: boolean) => { + // Windows-only: Invert scroll metrics when inverted prop is + // set to retain monotonically increasing layout assumptions + // in the direction of increasing scroll offsets. + let scrollMetrics = this._scrollMetrics; + if (inverted) { + const { + contentLength, + dOffset, + offset, + velocity, + visibleLength, + } = scrollMetrics; + scrollMetrics = { + ...scrollMetrics, + dOffset: dOffset * -1, + offset: contentLength - offset - visibleLength, + velocity: velocity * -1, + }; + } + return scrollMetrics; }; hasMore(): boolean { @@ -580,7 +599,7 @@ export default class VirtualizedList extends StateSafePureComponent< // where the list is shorter than the visible area) if ( props.initialScrollIndex && - !this._scrollMetrics.offset && + !offset && Math.abs(distanceFromEnd) >= Number.EPSILON ) { return cellsAroundViewport.last >= getItemCount(data) @@ -594,7 +613,7 @@ export default class VirtualizedList extends StateSafePureComponent< windowSizeOrDefault(props.windowSize), cellsAroundViewport, this.__getFrameMetricsApprox, - this._scrollMetrics, + this._getScrollMetrics(props.inverted), ); invariant( newCellsAroundViewport.last < getItemCount(data), @@ -812,6 +831,12 @@ export default class VirtualizedList extends StateSafePureComponent< ? styles.horizontallyInverted : styles.verticallyInverted : null; + // Windows-only: Reverse the layout of items via flex + const containerInversionStyle = this.props.inverted + ? this.props.horizontal + ? styles.horizontallyReversed + : styles.verticallyReversed + : null; const cells: Array = []; const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices); const stickyHeaderIndices = []; @@ -913,7 +938,10 @@ export default class VirtualizedList extends StateSafePureComponent< cells.push( , ); } else { @@ -969,6 +997,11 @@ export default class VirtualizedList extends StateSafePureComponent< // 4. Render the ScrollView const scrollProps = { ...this.props, + // Windows-only: Pass through inverted container styles + contentContainerStyle: StyleSheet.compose( + containerInversionStyle, + this.props.contentContainerStyle, + ), onContentSizeChange: this._onContentSizeChange, onLayout: this._onLayout, onScroll: this._onScroll, @@ -995,7 +1028,7 @@ export default class VirtualizedList extends StateSafePureComponent< this._getScrollMetrics(this.props.inverted), horizontal: horizontalOrDefault(this.props.horizontal), getOutermostParentListRef: this._getOutermostParentListRef, registerAsNestedChild: this._registerAsNestedChild, @@ -1129,7 +1162,7 @@ export default class VirtualizedList extends StateSafePureComponent< this._fillRateHelper.computeBlankness( this.props, this.state.cellsAroundViewport, - this._scrollMetrics, + this._getScrollMetrics(this.props.inverted), ); } @@ -1328,7 +1361,7 @@ export default class VirtualizedList extends StateSafePureComponent< const framesInLayout = []; const itemCount = this.props.getItemCount(this.props.data); for (let ii = 0; ii < itemCount; ii++) { - const frame = this.__getFrameMetricsApprox(ii, this.props); + const frame = this.__getFrameMetricsApprox(ii, this.props, /* useRawMetrics: */ true); /* $FlowFixMe[prop-missing] (>=0.68.0 site=react_native_fb) This comment * suppresses an error found when Flow v0.68 was deployed. To see the * error delete this comment and run Flow. */ @@ -1368,7 +1401,8 @@ export default class VirtualizedList extends StateSafePureComponent< styles.debugOverlayBase, styles.debugOverlayFrameLast, { - top: windowTop * normalize, + // Windows-only: Invert the position of the render window offset + top: (this.props.inverted ? this._scrollMetrics.contentLength - windowLen - windowTop : windowTop) * normalize, height: windowLen * normalize, }, ]} @@ -1412,7 +1446,7 @@ export default class VirtualizedList extends StateSafePureComponent< _maybeCallOnEndReached() { const {data, getItemCount, onEndReached, onEndReachedThreshold} = this.props; - const {contentLength, visibleLength, offset} = this._scrollMetrics; + const {contentLength, visibleLength, offset} = this._getScrollMetrics(this.props.inverted); let distanceFromEnd = contentLength - visibleLength - offset; // Especially when oERT is zero it's necessary to 'floor' very small distanceFromEnd values to be 0 @@ -1560,7 +1594,7 @@ export default class VirtualizedList extends StateSafePureComponent< _scheduleCellsToRenderUpdate() { const {first, last} = this.state.cellsAroundViewport; - const {offset, visibleLength, velocity} = this._scrollMetrics; + const {offset, visibleLength, velocity} = this._getScrollMetrics(this.props.inverted); const itemCount = this.props.getItemCount(this.props.data); let hiPri = false; const onEndReachedThreshold = onEndReachedThresholdOrDefault( @@ -1708,15 +1742,26 @@ export default class VirtualizedList extends StateSafePureComponent< __getFrameMetricsApprox: ( index: number, props: FrameMetricProps, + useRawMetrics?: boolean, ) => { length: number, offset: number, ... - } = (index, props) => { + } = (index, props, useRawMetrics) => { const frame = this._getFrameMetrics(index, props); if (frame && frame.index === index) { - // check for invalid frames due to row re-ordering - return frame; + // Windows-only: Raw metrics are requested for scroll commands. Metrics + // returned from __getFrameMetrics are assumed to be inverted. To convert back + // to raw metrics, subtract the offset and length from the content length. + return props.inverted && useRawMetrics + ? { + ...frame, + offset: Math.max( + 0, + this._scrollMetrics.contentLength - frame.offset - frame.length, + ), + } + : frame; } else { const {data, getItemCount, getItemLayout} = props; invariant( @@ -1727,9 +1772,21 @@ export default class VirtualizedList extends StateSafePureComponent< !getItemLayout, 'Should not have to estimate frames when a measurement metrics function is provided', ); + + // Windows-only: Raw metrics are requested for scroll commands. Metrics + // returned from _getFrameMetrics are assumed to be inverted. To compute + // approximate raw metrics, subtract the computed average offset from + // the content length. + const offset = + props.inverted && useRawMetrics + ? Math.max( + 0, + this._scrollMetrics - this._averageCellLength * (index + 1), + ) + : this._averageCellLength * index; return { length: this._averageCellLength, - offset: this._averageCellLength * index, + offset, }; } }; @@ -1750,7 +1807,14 @@ export default class VirtualizedList extends StateSafePureComponent< 'Tried to get frame for out of range index ' + index, ); const item = getItem(data, index); - const frame = item && this._frames[this._keyExtractor(item, index, props)]; + let frame = item && this._frames[this._keyExtractor(item, index, props)]; + // Windows-only: Convert to inverted offsets from raw layout + if (frame && props.inverted) { + frame = { + ...frame, + offset: this._scrollMetrics.contentLength - frame.offset - frame.length, + }; + } if (!frame || frame.index !== index) { if (getItemLayout) { /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment @@ -1828,7 +1892,7 @@ export default class VirtualizedList extends StateSafePureComponent< this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.onUpdate( props, - this._scrollMetrics.offset, + this._getScrollMetrics(props.inverted).offset, this._scrollMetrics.visibleLength, this._getFrameMetrics, this._createViewToken, @@ -1841,10 +1905,16 @@ export default class VirtualizedList extends StateSafePureComponent< const styles = StyleSheet.create({ verticallyInverted: { - transform: [{scaleY: -1}], + /* Windows-only: do not use transform-based inversion */ }, horizontallyInverted: { - transform: [{scaleX: -1}], + /* Windows-only: do not use transform-based inversion */ + }, + verticallyReversed: { + flexDirection: 'column-reverse', + }, + horizontallyReversed: { + flexDirection: 'row-reverse', }, debug: { flex: 1, diff --git a/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js b/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js index 62307e471a1..2c30ece1ea0 100644 --- a/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js +++ b/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js @@ -274,6 +274,7 @@ getItemCount: RequiredProps['getItemCount'], getItem: RequiredProps['getItem'], getItemLayout?: OptionalProps['getItemLayout'], + inverted: OptionalProps['inverted'], keyExtractor?: OptionalProps['keyExtractor'], ... }; From bdd44048fd4e872116de78c51f398a06f0a1f0ec Mon Sep 17 00:00:00 2001 From: Eric Rozell Date: Tue, 31 Jan 2023 11:49:05 -0500 Subject: [PATCH 10/11] Allow ScrollViewViewChanger to be used in WinUI Fabric --- .../Views/Impl/ScrollViewViewChanger.cpp | 20 +++++++++++++-- .../Views/Impl/ScrollViewViewChanger.h | 2 ++ .../Views/ScrollContentViewManager.cpp | 3 ++- .../Views/ViewViewManager.cpp | 25 ++++--------------- .../Views/ViewViewManager.h | 2 -- .../Lists/VirtualizedList.windows.js | 4 ++- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp index 85791376d0d..1f7079443f8 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.cpp @@ -4,15 +4,31 @@ #include "pch.h" #include -#include +#include #include "ScrollViewUWPImplementation.h" #include "ScrollViewViewChanger.h" #include "SnapPointManagingContentControl.h" +namespace winrt { +using namespace winrt::Windows::UI::Xaml::Interop; +} + namespace Microsoft::ReactNative { constexpr const double SCROLL_EPSILON = 1.0; +const winrt::TypeName viewViewManagerTypeName{winrt::hstring{L"ViewViewManager"}, winrt::TypeKind::Metadata}; + +/*static*/ xaml::DependencyProperty ScrollViewViewChanger::CanBeScrollAnchorProperty() { + static xaml::DependencyProperty s_canBeScrollAnchorProperty = xaml::DependencyProperty::RegisterAttached( + L"CanBeScrollAnchor", + winrt::xaml_typename(), + viewViewManagerTypeName, + winrt::PropertyMetadata(winrt::box_value(true))); + + return s_canBeScrollAnchorProperty; +} + void ScrollViewViewChanger::Horizontal(bool horizontal) { m_horizontal = horizontal; } @@ -79,7 +95,7 @@ void ScrollViewViewChanger::UpdateScrollAnchoringEnabled( auto panel = snapPointManager->Content().as(); for (auto child : panel.Children()) { const auto childElement = child.as(); - if (winrt::unbox_value(childElement.GetValue(ViewViewManager::CanBeScrollAnchorProperty()))) { + if (winrt::unbox_value(childElement.GetValue(CanBeScrollAnchorProperty()))) { if (enabled) { childElement.CanBeScrollAnchor(true); } else { diff --git a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h index 8dc8a9f3337..6fd78277ebb 100644 --- a/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h +++ b/vnext/Microsoft.ReactNative/Views/Impl/ScrollViewViewChanger.h @@ -7,6 +7,8 @@ namespace Microsoft::ReactNative { class ScrollViewViewChanger { public: + static xaml::DependencyProperty CanBeScrollAnchorProperty(); + void Horizontal(bool horizontal); void Inverted(bool inverted); diff --git a/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp b/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp index f1533cea5cb..2f06ebeeb1c 100644 --- a/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp +++ b/vnext/Microsoft.ReactNative/Views/ScrollContentViewManager.cpp @@ -7,6 +7,7 @@ #include #include +#include "Impl/ScrollViewViewChanger.h" #include "Impl/SnapPointManagingContentControl.h" #include "ViewPanel.h" @@ -34,7 +35,7 @@ void ScrollContentViewManager::AddView(const XamlView &parent, const XamlView &c if (viewParent) { const auto scrollViewContentControl = viewParent.as(); if (scrollViewContentControl->IsInverted() && scrollViewContentControl->IsContentAnchoringEnabled()) { - if (winrt::unbox_value(child.GetValue(ViewViewManager::CanBeScrollAnchorProperty()))) { + if (winrt::unbox_value(child.GetValue(ScrollViewViewChanger::CanBeScrollAnchorProperty()))) { childElement.CanBeScrollAnchor(true); } } diff --git a/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp b/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp index 0c1bebb6dee..174bb98773d 100644 --- a/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp +++ b/vnext/Microsoft.ReactNative/Views/ViewViewManager.cpp @@ -5,6 +5,7 @@ #include "ViewViewManager.h" #include +#include "Impl/ScrollViewViewChanger.h" #include "ViewControl.h" #include @@ -23,7 +24,6 @@ #include #include #include -#include #include #if defined(_DEBUG) @@ -33,10 +33,6 @@ using namespace facebook::react; -namespace winrt { -using namespace winrt::Windows::UI::Xaml::Interop; -} - namespace Microsoft::ReactNative { // ViewShadowNode @@ -356,18 +352,6 @@ bool TryUpdateBorderProperties( ViewViewManager::ViewViewManager(const Mso::React::IReactContext &context) : Super(context) {} -const winrt::TypeName viewViewManagerTypeName{winrt::hstring{L"ViewViewManager"}, winrt::TypeKind::Metadata}; - -/*static*/ xaml::DependencyProperty ViewViewManager::CanBeScrollAnchorProperty() { - static xaml::DependencyProperty s_canBeScrollAnchorProperty = xaml::DependencyProperty::RegisterAttached( - L"CanBeScrollAnchor", - winrt::xaml_typename(), - viewViewManagerTypeName, - winrt::PropertyMetadata(winrt::box_value(true))); - - return s_canBeScrollAnchorProperty; -} - const wchar_t *ViewViewManager::GetName() const { return L"RCTView"; } @@ -468,12 +452,13 @@ bool ViewViewManager::UpdateProperty( #ifndef USE_WINUI3 if (propertyValue.Type() == React::JSValueType::String) { if (propertyValue.AsString() == "none") { - pViewShadowNode->GetView().SetValue(CanBeScrollAnchorProperty(), winrt::box_value(false)); + pViewShadowNode->GetView().SetValue( + ScrollViewViewChanger::CanBeScrollAnchorProperty(), winrt::box_value(false)); } else { - pViewShadowNode->GetView().ClearValue(CanBeScrollAnchorProperty()); + pViewShadowNode->GetView().ClearValue(ScrollViewViewChanger::CanBeScrollAnchorProperty()); } } else if (propertyValue.IsNull()) { - pViewShadowNode->GetView().ClearValue(CanBeScrollAnchorProperty()); + pViewShadowNode->GetView().ClearValue(ScrollViewViewChanger::CanBeScrollAnchorProperty()); } #endif } else { diff --git a/vnext/Microsoft.ReactNative/Views/ViewViewManager.h b/vnext/Microsoft.ReactNative/Views/ViewViewManager.h index 0eddb81c7e2..413474689c9 100644 --- a/vnext/Microsoft.ReactNative/Views/ViewViewManager.h +++ b/vnext/Microsoft.ReactNative/Views/ViewViewManager.h @@ -14,8 +14,6 @@ class ViewViewManager : public FrameworkElementViewManager { using Super = FrameworkElementViewManager; public: - static xaml::DependencyProperty CanBeScrollAnchorProperty(); - ViewViewManager(const Mso::React::IReactContext &context); const wchar_t *GetName() const override; diff --git a/vnext/src/Libraries/Lists/VirtualizedList.windows.js b/vnext/src/Libraries/Lists/VirtualizedList.windows.js index 82960a68a0a..a34ad8cd7f5 100644 --- a/vnext/src/Libraries/Lists/VirtualizedList.windows.js +++ b/vnext/src/Libraries/Lists/VirtualizedList.windows.js @@ -561,7 +561,7 @@ export default class VirtualizedList extends StateSafePureComponent< ); this._updateViewableItems(props, cellsAroundViewport); - const {contentLength, offset, visibleLength} = this._scrollMetrics; + const {contentLength, offset, visibleLength} = this._getScrollMetrics(props.inverted); const distanceFromEnd = contentLength - visibleLength - offset; // Wait until the scroll view metrics have been set up. And until then, @@ -859,6 +859,7 @@ export default class VirtualizedList extends StateSafePureComponent< key="$header"> Date: Mon, 6 Feb 2023 16:27:19 -0500 Subject: [PATCH 11/11] yarn lint:fix --- .../Lists/VirtualizedList.windows.js | 36 +- .../Lists/VirtualizedListProps.windows.js | 524 +++++++++--------- 2 files changed, 285 insertions(+), 275 deletions(-) diff --git a/vnext/src/Libraries/Lists/VirtualizedList.windows.js b/vnext/src/Libraries/Lists/VirtualizedList.windows.js index a34ad8cd7f5..c2ae79d0aeb 100644 --- a/vnext/src/Libraries/Lists/VirtualizedList.windows.js +++ b/vnext/src/Libraries/Lists/VirtualizedList.windows.js @@ -157,7 +157,11 @@ export default class VirtualizedList extends StateSafePureComponent< scrollToEnd(params?: ?{animated?: ?boolean, ...}) { const animated = params ? params.animated : true; const veryLast = this.props.getItemCount(this.props.data) - 1; - const frame = this.__getFrameMetricsApprox(veryLast, this.props, /* useRawMetrics: */ true); + const frame = this.__getFrameMetricsApprox( + veryLast, + this.props, + /* useRawMetrics: */ true, + ); const offset = Math.max( 0, frame.offset + @@ -375,13 +379,8 @@ export default class VirtualizedList extends StateSafePureComponent< // in the direction of increasing scroll offsets. let scrollMetrics = this._scrollMetrics; if (inverted) { - const { - contentLength, - dOffset, - offset, - velocity, - visibleLength, - } = scrollMetrics; + const {contentLength, dOffset, offset, velocity, visibleLength} = + scrollMetrics; scrollMetrics = { ...scrollMetrics, dOffset: dOffset * -1, @@ -940,7 +939,7 @@ export default class VirtualizedList extends StateSafePureComponent< , @@ -1363,7 +1362,11 @@ export default class VirtualizedList extends StateSafePureComponent< const framesInLayout = []; const itemCount = this.props.getItemCount(this.props.data); for (let ii = 0; ii < itemCount; ii++) { - const frame = this.__getFrameMetricsApprox(ii, this.props, /* useRawMetrics: */ true); + const frame = this.__getFrameMetricsApprox( + ii, + this.props, + /* useRawMetrics: */ true, + ); /* $FlowFixMe[prop-missing] (>=0.68.0 site=react_native_fb) This comment * suppresses an error found when Flow v0.68 was deployed. To see the * error delete this comment and run Flow. */ @@ -1404,7 +1407,10 @@ export default class VirtualizedList extends StateSafePureComponent< styles.debugOverlayFrameLast, { // Windows-only: Invert the position of the render window offset - top: (this.props.inverted ? this._scrollMetrics.contentLength - windowLen - windowTop : windowTop) * normalize, + top: + (this.props.inverted + ? this._scrollMetrics.contentLength - windowLen - windowTop + : windowTop) * normalize, height: windowLen * normalize, }, ]} @@ -1448,7 +1454,9 @@ export default class VirtualizedList extends StateSafePureComponent< _maybeCallOnEndReached() { const {data, getItemCount, onEndReached, onEndReachedThreshold} = this.props; - const {contentLength, visibleLength, offset} = this._getScrollMetrics(this.props.inverted); + const {contentLength, visibleLength, offset} = this._getScrollMetrics( + this.props.inverted, + ); let distanceFromEnd = contentLength - visibleLength - offset; // Especially when oERT is zero it's necessary to 'floor' very small distanceFromEnd values to be 0 @@ -1596,7 +1604,9 @@ export default class VirtualizedList extends StateSafePureComponent< _scheduleCellsToRenderUpdate() { const {first, last} = this.state.cellsAroundViewport; - const {offset, visibleLength, velocity} = this._getScrollMetrics(this.props.inverted); + const {offset, visibleLength, velocity} = this._getScrollMetrics( + this.props.inverted, + ); const itemCount = this.props.getItemCount(this.props.data); let hiPri = false; const onEndReachedThreshold = onEndReachedThresholdOrDefault( diff --git a/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js b/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js index 2c30ece1ea0..2df3812033c 100644 --- a/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js +++ b/vnext/src/Libraries/Lists/VirtualizedListProps.windows.js @@ -8,273 +8,273 @@ * @format */ - import typeof ScrollView from '../Components/ScrollView/ScrollView'; - import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; - import type { - ViewabilityConfig, - ViewabilityConfigCallbackPair, - ViewToken, - } from './ViewabilityHelper'; +import typeof ScrollView from '../Components/ScrollView/ScrollView'; +import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; +import type { + ViewabilityConfig, + ViewabilityConfigCallbackPair, + ViewToken, +} from './ViewabilityHelper'; - import * as React from 'react'; +import * as React from 'react'; - export type Item = any; +export type Item = any; - export type Separators = { - highlight: () => void, - unhighlight: () => void, - updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, - ... - }; +export type Separators = { + highlight: () => void, + unhighlight: () => void, + updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, + ... +}; - export type RenderItemProps = { - item: ItemT, - index: number, - separators: Separators, - ... - }; +export type RenderItemProps = { + item: ItemT, + index: number, + separators: Separators, + ... +}; - export type RenderItemType = ( - info: RenderItemProps, - ) => React.Node; +export type RenderItemType = ( + info: RenderItemProps, +) => React.Node; - type RequiredProps = {| - /** - * The default accessor functions assume this is an Array<{key: string} | {id: string}> but you can override - * getItem, getItemCount, and keyExtractor to handle any type of index-based data. - */ - data?: any, - /** - * A generic accessor for extracting an item from any sort of data blob. - */ - getItem: (data: any, index: number) => ?Item, - /** - * Determines how many items are in the data blob. - */ - getItemCount: (data: any) => number, - |}; - type OptionalProps = {| - renderItem?: ?RenderItemType, - /** - * `debug` will turn on extra logging and visual overlays to aid with debugging both usage and - * implementation, but with a significant perf hit. - */ - debug?: ?boolean, - /** - * DEPRECATED: Virtualization provides significant performance and memory optimizations, but fully - * unmounts react instances that are outside of the render window. You should only need to disable - * this for debugging purposes. Defaults to false. - */ - disableVirtualization?: ?boolean, - /** - * A marker property for telling the list to re-render (since it implements `PureComponent`). If - * any of your `renderItem`, Header, Footer, etc. functions depend on anything outside of the - * `data` prop, stick it here and treat it immutably. - */ - extraData?: any, - // e.g. height, y - getItemLayout?: ( - data: any, - index: number, - ) => { - length: number, - offset: number, - index: number, - ... - }, - horizontal?: ?boolean, - /** - * How many items to render in the initial batch. This should be enough to fill the screen but not - * much more. Note these items will never be unmounted as part of the windowed rendering in order - * to improve perceived performance of scroll-to-top actions. - */ - initialNumToRender?: ?number, - /** - * Instead of starting at the top with the first item, start at `initialScrollIndex`. This - * disables the "scroll to top" optimization that keeps the first `initialNumToRender` items - * always rendered and immediately renders the items starting at this initial index. Requires - * `getItemLayout` to be implemented. - */ - initialScrollIndex?: ?number, - /** - * Reverses the direction of scroll. Uses scale transforms of -1. - */ - inverted?: ?boolean, - keyExtractor?: ?(item: Item, index: number) => string, - /** - * Each cell is rendered using this element. Can be a React Component Class, - * or a render function. Defaults to using View. - */ - CellRendererComponent?: ?React.ComponentType, - /** - * Rendered in between each item, but not at the top or bottom. By default, `highlighted` and - * `leadingItem` props are provided. `renderItem` provides `separators.highlight`/`unhighlight` - * which will update the `highlighted` prop, but you can also add custom props with - * `separators.updateProps`. - */ - ItemSeparatorComponent?: ?React.ComponentType, - /** - * Takes an item from `data` and renders it into the list. Example usage: - * - * ( - * - * )} - * data={[{title: 'Title Text', key: 'item1'}]} - * ListItemComponent={({item, separators}) => ( - * this._onPress(item)} - * onShowUnderlay={separators.highlight} - * onHideUnderlay={separators.unhighlight}> - * - * {item.title} - * - * - * )} - * /> - * - * Provides additional metadata like `index` if you need it, as well as a more generic - * `separators.updateProps` function which let's you set whatever props you want to change the - * rendering of either the leading separator or trailing separator in case the more common - * `highlight` and `unhighlight` (which set the `highlighted: boolean` prop) are insufficient for - * your use-case. - */ - ListItemComponent?: ?(React.ComponentType | React.Element), - /** - * Rendered when the list is empty. Can be a React Component Class, a render function, or - * a rendered element. - */ - ListEmptyComponent?: ?(React.ComponentType | React.Element), - /** - * Rendered at the bottom of all the items. Can be a React Component Class, a render function, or - * a rendered element. - */ - ListFooterComponent?: ?(React.ComponentType | React.Element), - /** - * Styling for internal View for ListFooterComponent - */ - ListFooterComponentStyle?: ViewStyleProp, - /** - * Rendered at the top of all the items. Can be a React Component Class, a render function, or - * a rendered element. - */ - ListHeaderComponent?: ?(React.ComponentType | React.Element), - /** - * Styling for internal View for ListHeaderComponent - */ - ListHeaderComponentStyle?: ViewStyleProp, - /** - * The maximum number of items to render in each incremental render batch. The more rendered at - * once, the better the fill rate, but responsiveness may suffer because rendering content may - * interfere with responding to button taps or other interactions. - */ - maxToRenderPerBatch?: ?number, - /** - * Called once when the scroll position gets within `onEndReachedThreshold` of the rendered - * content. - */ - onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void, - /** - * How far from the end (in units of visible length of the list) the bottom edge of the - * list must be from the end of the content to trigger the `onEndReached` callback. - * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is - * within half the visible length of the list. A value of 0 will not trigger until scrolling - * to the very end of the list. - */ - onEndReachedThreshold?: ?number, - /** - * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make - * sure to also set the `refreshing` prop correctly. - */ - onRefresh?: ?() => void, - /** - * Used to handle failures when scrolling to an index that has not been measured yet. Recommended - * action is to either compute your own offset and `scrollTo` it, or scroll as far as possible and - * then try again after more items have been rendered. - */ - onScrollToIndexFailed?: ?(info: { - index: number, - highestMeasuredFrameIndex: number, - averageItemLength: number, - ... - }) => void, - /** - * Called when the viewability of rows changes, as defined by the - * `viewabilityConfig` prop. - */ - onViewableItemsChanged?: ?(info: { - viewableItems: Array, - changed: Array, - ... - }) => void, - persistentScrollbar?: ?boolean, - /** - * Set this when offset is needed for the loading indicator to show correctly. - */ - progressViewOffset?: number, - /** - * A custom refresh control element. When set, it overrides the default - * component built internally. The onRefresh and refreshing - * props are also ignored. Only works for vertical VirtualizedList. - */ - refreshControl?: ?React.Element, - /** - * Set this true while waiting for new data from a refresh. - */ - refreshing?: ?boolean, - /** - * Note: may have bugs (missing content) in some circumstances - use at your own risk. - * - * This may improve scroll performance for large lists. - */ - removeClippedSubviews?: boolean, - /** - * Render a custom scroll component, e.g. with a differently styled `RefreshControl`. - */ - renderScrollComponent?: (props: Object) => React.Element, - /** - * Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off - * screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`. - */ - updateCellsBatchingPeriod?: ?number, - /** - * See `ViewabilityHelper` for flow type and further documentation. - */ - viewabilityConfig?: ViewabilityConfig, - /** - * List of ViewabilityConfig/onViewableItemsChanged pairs. A specific onViewableItemsChanged - * will be called when its corresponding ViewabilityConfig's conditions are met. - */ - viewabilityConfigCallbackPairs?: Array, - /** - * Determines the maximum number of items rendered outside of the visible area, in units of - * visible lengths. So if your list fills the screen, then `windowSize={21}` (the default) will - * render the visible screen area plus up to 10 screens above and 10 below the viewport. Reducing - * this number will reduce memory consumption and may improve performance, but will increase the - * chance that fast scrolling may reveal momentary blank areas of unrendered content. - */ - windowSize?: ?number, - /** - * The legacy implementation is no longer supported. - */ - legacyImplementation?: empty, - |}; +type RequiredProps = {| + /** + * The default accessor functions assume this is an Array<{key: string} | {id: string}> but you can override + * getItem, getItemCount, and keyExtractor to handle any type of index-based data. + */ + data?: any, + /** + * A generic accessor for extracting an item from any sort of data blob. + */ + getItem: (data: any, index: number) => ?Item, + /** + * Determines how many items are in the data blob. + */ + getItemCount: (data: any) => number, +|}; +type OptionalProps = {| + renderItem?: ?RenderItemType, + /** + * `debug` will turn on extra logging and visual overlays to aid with debugging both usage and + * implementation, but with a significant perf hit. + */ + debug?: ?boolean, + /** + * DEPRECATED: Virtualization provides significant performance and memory optimizations, but fully + * unmounts react instances that are outside of the render window. You should only need to disable + * this for debugging purposes. Defaults to false. + */ + disableVirtualization?: ?boolean, + /** + * A marker property for telling the list to re-render (since it implements `PureComponent`). If + * any of your `renderItem`, Header, Footer, etc. functions depend on anything outside of the + * `data` prop, stick it here and treat it immutably. + */ + extraData?: any, + // e.g. height, y + getItemLayout?: ( + data: any, + index: number, + ) => { + length: number, + offset: number, + index: number, + ... + }, + horizontal?: ?boolean, + /** + * How many items to render in the initial batch. This should be enough to fill the screen but not + * much more. Note these items will never be unmounted as part of the windowed rendering in order + * to improve perceived performance of scroll-to-top actions. + */ + initialNumToRender?: ?number, + /** + * Instead of starting at the top with the first item, start at `initialScrollIndex`. This + * disables the "scroll to top" optimization that keeps the first `initialNumToRender` items + * always rendered and immediately renders the items starting at this initial index. Requires + * `getItemLayout` to be implemented. + */ + initialScrollIndex?: ?number, + /** + * Reverses the direction of scroll. Uses scale transforms of -1. + */ + inverted?: ?boolean, + keyExtractor?: ?(item: Item, index: number) => string, + /** + * Each cell is rendered using this element. Can be a React Component Class, + * or a render function. Defaults to using View. + */ + CellRendererComponent?: ?React.ComponentType, + /** + * Rendered in between each item, but not at the top or bottom. By default, `highlighted` and + * `leadingItem` props are provided. `renderItem` provides `separators.highlight`/`unhighlight` + * which will update the `highlighted` prop, but you can also add custom props with + * `separators.updateProps`. + */ + ItemSeparatorComponent?: ?React.ComponentType, + /** + * Takes an item from `data` and renders it into the list. Example usage: + * + * ( + * + * )} + * data={[{title: 'Title Text', key: 'item1'}]} + * ListItemComponent={({item, separators}) => ( + * this._onPress(item)} + * onShowUnderlay={separators.highlight} + * onHideUnderlay={separators.unhighlight}> + * + * {item.title} + * + * + * )} + * /> + * + * Provides additional metadata like `index` if you need it, as well as a more generic + * `separators.updateProps` function which let's you set whatever props you want to change the + * rendering of either the leading separator or trailing separator in case the more common + * `highlight` and `unhighlight` (which set the `highlighted: boolean` prop) are insufficient for + * your use-case. + */ + ListItemComponent?: ?(React.ComponentType | React.Element), + /** + * Rendered when the list is empty. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListEmptyComponent?: ?(React.ComponentType | React.Element), + /** + * Rendered at the bottom of all the items. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListFooterComponent?: ?(React.ComponentType | React.Element), + /** + * Styling for internal View for ListFooterComponent + */ + ListFooterComponentStyle?: ViewStyleProp, + /** + * Rendered at the top of all the items. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListHeaderComponent?: ?(React.ComponentType | React.Element), + /** + * Styling for internal View for ListHeaderComponent + */ + ListHeaderComponentStyle?: ViewStyleProp, + /** + * The maximum number of items to render in each incremental render batch. The more rendered at + * once, the better the fill rate, but responsiveness may suffer because rendering content may + * interfere with responding to button taps or other interactions. + */ + maxToRenderPerBatch?: ?number, + /** + * Called once when the scroll position gets within `onEndReachedThreshold` of the rendered + * content. + */ + onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void, + /** + * How far from the end (in units of visible length of the list) the bottom edge of the + * list must be from the end of the content to trigger the `onEndReached` callback. + * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is + * within half the visible length of the list. A value of 0 will not trigger until scrolling + * to the very end of the list. + */ + onEndReachedThreshold?: ?number, + /** + * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make + * sure to also set the `refreshing` prop correctly. + */ + onRefresh?: ?() => void, + /** + * Used to handle failures when scrolling to an index that has not been measured yet. Recommended + * action is to either compute your own offset and `scrollTo` it, or scroll as far as possible and + * then try again after more items have been rendered. + */ + onScrollToIndexFailed?: ?(info: { + index: number, + highestMeasuredFrameIndex: number, + averageItemLength: number, + ... + }) => void, + /** + * Called when the viewability of rows changes, as defined by the + * `viewabilityConfig` prop. + */ + onViewableItemsChanged?: ?(info: { + viewableItems: Array, + changed: Array, + ... + }) => void, + persistentScrollbar?: ?boolean, + /** + * Set this when offset is needed for the loading indicator to show correctly. + */ + progressViewOffset?: number, + /** + * A custom refresh control element. When set, it overrides the default + * component built internally. The onRefresh and refreshing + * props are also ignored. Only works for vertical VirtualizedList. + */ + refreshControl?: ?React.Element, + /** + * Set this true while waiting for new data from a refresh. + */ + refreshing?: ?boolean, + /** + * Note: may have bugs (missing content) in some circumstances - use at your own risk. + * + * This may improve scroll performance for large lists. + */ + removeClippedSubviews?: boolean, + /** + * Render a custom scroll component, e.g. with a differently styled `RefreshControl`. + */ + renderScrollComponent?: (props: Object) => React.Element, + /** + * Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off + * screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`. + */ + updateCellsBatchingPeriod?: ?number, + /** + * See `ViewabilityHelper` for flow type and further documentation. + */ + viewabilityConfig?: ViewabilityConfig, + /** + * List of ViewabilityConfig/onViewableItemsChanged pairs. A specific onViewableItemsChanged + * will be called when its corresponding ViewabilityConfig's conditions are met. + */ + viewabilityConfigCallbackPairs?: Array, + /** + * Determines the maximum number of items rendered outside of the visible area, in units of + * visible lengths. So if your list fills the screen, then `windowSize={21}` (the default) will + * render the visible screen area plus up to 10 screens above and 10 below the viewport. Reducing + * this number will reduce memory consumption and may improve performance, but will increase the + * chance that fast scrolling may reveal momentary blank areas of unrendered content. + */ + windowSize?: ?number, + /** + * The legacy implementation is no longer supported. + */ + legacyImplementation?: empty, +|}; - export type Props = {| - ...React.ElementConfig, - ...RequiredProps, - ...OptionalProps, - |}; +export type Props = {| + ...React.ElementConfig, + ...RequiredProps, + ...OptionalProps, +|}; - /** - * Subset of properties needed to calculate frame metrics - */ - export type FrameMetricProps = { - data: RequiredProps['data'], - getItemCount: RequiredProps['getItemCount'], - getItem: RequiredProps['getItem'], - getItemLayout?: OptionalProps['getItemLayout'], - inverted: OptionalProps['inverted'], - keyExtractor?: OptionalProps['keyExtractor'], - ... - }; +/** + * Subset of properties needed to calculate frame metrics + */ +export type FrameMetricProps = { + data: RequiredProps['data'], + getItemCount: RequiredProps['getItemCount'], + getItem: RequiredProps['getItem'], + getItemLayout?: OptionalProps['getItemLayout'], + inverted: OptionalProps['inverted'], + keyExtractor?: OptionalProps['keyExtractor'], + ... +};