├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .justfile ├── .luaurc ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── aftman.toml ├── default.project.json ├── dev.project.json ├── selene.toml ├── src ├── Components │ ├── ScrollView │ │ ├── ScrollContentViewNativeComponent.luau │ │ ├── ScrollView.luau │ │ ├── ScrollViewCommands.luau │ │ ├── ScrollViewContext.luau │ │ ├── ScrollViewNativeComponent.luau │ │ ├── ScrollViewStickyHeader.luau │ │ ├── ScrollViewViewConfig.luau │ │ └── processDecelerationRate.luau │ └── View │ │ └── View.luau ├── Interaction │ └── Batchinator.luau ├── Lists │ ├── AnimatedFlatList.luau │ ├── CellRenderMask.luau │ ├── FillRateHelper.luau │ ├── FlatList.luau │ ├── Hooks │ │ ├── init.luau │ │ └── useFocusNavigationScrolling.luau │ ├── SectionList.luau │ ├── ViewabilityHelper.luau │ ├── VirtualizeUtils.luau │ ├── VirtualizedList.luau │ ├── VirtualizedListContext.luau │ └── VirtualizedSectionList.luau ├── StyleSheet │ └── StyleSheet.luau ├── Utilities │ ├── codegenNativeCommands.luau │ ├── differ │ │ └── deepDiffer.luau │ ├── infoLog.luau │ └── setAndForwardRef.luau ├── init.luau └── jsUtils │ └── invariant.luau ├── stylua.toml ├── wally.lock └── wally.toml /.gitattributes: -------------------------------------------------------------------------------- 1 | *.luau linguist-language=Lua 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Install Aftman 21 | uses: ok-nick/setup-aftman@v0 22 | 23 | - name: Install Just 24 | uses: extractions/setup-just@v1 25 | 26 | - name: Analyze 27 | shell: bash 28 | run: just analyze 29 | 30 | lint: 31 | name: Lint 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout code 35 | uses: actions/checkout@v3 36 | 37 | - name: Install Aftman 38 | uses: ok-nick/setup-aftman@v0 39 | 40 | - name: Lint 41 | run: | 42 | selene ./src 43 | 44 | style: 45 | name: Styling 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@v3 50 | 51 | - name: Check code style 52 | uses: JohnnyMorganz/stylua-action@v2 53 | with: 54 | token: ${{ secrets.GITHUB_TOKEN }} 55 | version: v0.17.1 56 | args: --check ./src 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | submodules: "recursive" 17 | 18 | - name: Install Aftman 19 | uses: ok-nick/setup-aftman@v0 20 | 21 | - name: Authenticate Wally 22 | run: wally login --token ${{ secrets.WALLY_ACCESS_TOKEN }} 23 | 24 | - name: Publish package 25 | run: wally publish 26 | 27 | - name: Build project 28 | run: rojo build --output VirtualizedList.rbxm 29 | 30 | - name: Create GitHub Release 31 | id: create_release 32 | uses: ncipollo/release-action@v1 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | with: 36 | name: Release ${{ github.ref }} 37 | tag: ${{ github.ref }} 38 | draft: true 39 | 40 | - name: Upload build artifact 41 | uses: actions/upload-release-asset@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | with: 45 | upload_url: ${{ steps.create_release.outputs.upload_url }} 46 | asset_path: ./VirtualizedList.rbxm 47 | asset_name: VirtualizedList.rbxm 48 | asset_content_type: application/octet-stream 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages/ 2 | 3 | .DS_Store 4 | 5 | *.rbxl 6 | *.lock 7 | 8 | sourcemap.json 9 | globalTypes.d.lua 10 | -------------------------------------------------------------------------------- /.justfile: -------------------------------------------------------------------------------- 1 | analyze: install-packages 2 | rojo sourcemap dev.project.json --output sourcemap.json 3 | curl -O https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua 4 | luau-lsp analyze --definitions=globalTypes.d.lua --base-luaurc=.luaurc --sourcemap=sourcemap.json src/ 5 | 6 | # Installs packages and proxies their type information with `wally-package-types` tool 7 | # In addition, the packages/ directory is temporarily renamed so that it isn't removed by Wally 8 | install-packages: 9 | wally install 10 | 11 | rojo sourcemap dev.project.json --output sourcemap.json 12 | wally-package-types --sourcemap sourcemap.json Packages/ 13 | 14 | echo "{\"languageMode\": \"nonstrict\"}" > Packages/.luaurc 15 | -------------------------------------------------------------------------------- /.luaurc: -------------------------------------------------------------------------------- 1 | { 2 | "languageMode": "strict", 3 | "lint": { 4 | // Lints are handled by Selene 5 | "*": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "luau-lsp.sourcemap.rojoProjectFile": "dev.project.json" 3 | } 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | `brooke@gril.me`. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 JS.Lua 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # VirtualizedList Lua 4 | ### High-performance scrolling lists ported from React Native for [react-lua](https://github.com/jsdotlua/react-lua). 5 | 6 | 7 | license 8 | 9 | 10 | 11 | CI 12 | 13 | 14 | --- 15 | 16 | A collection of virtual list components, supporting the most handy features: 17 | 18 | - Fully cross-platform 19 | - Optional horizontal mode 20 | - Configurable viewability callbacks 21 | - Header support 22 | - Footer support 23 | - Separator support 24 | - Pull to Refresh 25 | - Scroll loading 26 | - ScrollToIndex support 27 | - Multiple column support 28 | - Animation suppot 29 | 30 | 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-priority (after any running interaction responses) if they are far from the visible area, or with high-priority otherwise to minimize the potential of seeing blank space. 31 | 32 | ## Caveats 33 | 34 | Virtualized lists aren't appropriate for all situations. Here's some caveats: 35 | 36 | - 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 an external store like Rodux. 37 | - Everything is a `PureComponent`, which means that it will not re-render if `props` are shallow-equal. Make sure that everything your `renderItem` function depends on is passed as a prop (e.g. `extraData`) that is not shallow-equal after updates, otherwise your UI may not update on changes. This includes the `data` prop and parent component state. 38 | - 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 and momentarily see blank content. This is a tradeoff that can be adjusted to suit the needs of each application. 39 | - By default, the lists look for a `key` prop on each item and uses that for the React key. Alternatively, you can provide a custom `keyExtractor` prop. 40 | 41 | ## Example 42 | 43 | ```lua 44 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 45 | local HttpService = game:GetService("HttpService") 46 | local Players = game:GetService("Players") 47 | 48 | local Packages = ReplicatedStorage.Packages 49 | 50 | local React = require(Packages.React) 51 | local ReactRoblox = require(Packages.ReactRoblox) 52 | local VirtualizedList = require(Packages.VirtualizedList) 53 | 54 | local View = VirtualizedList.View 55 | local FlatList = VirtualizedList.FlatList 56 | 57 | local e = React.createElement 58 | 59 | local ITEM_COUNT = 10_000 60 | local DATA = table.create(ITEM_COUNT) 61 | 62 | for i = 1, ITEM_COUNT do 63 | DATA[i] = { 64 | id = HttpService:GenerateGUID(false), 65 | title = `Item {i}`, 66 | } 67 | end 68 | 69 | local function Item(props) 70 | return e(View, {}, { 71 | ItemText = e("TextLabel", { 72 | Size = UDim2.new(1, 0, 0, 40), 73 | Text = props.title, 74 | }), 75 | }) 76 | end 77 | 78 | local function App() 79 | return e("ScreenGui", { 80 | ResetOnSpawn = false, 81 | ZIndexBehavior = Enum.ZIndexBehavior.Sibling, 82 | }, { 83 | Background = e("Frame", { 84 | AnchorPoint = Vector2.new(0.5, 0.5), 85 | Position = UDim2.fromScale(0.5, 0.5), 86 | Size = UDim2.fromScale(0.25, 0.4), 87 | }, { 88 | List = e(FlatList, { 89 | data = DATA, 90 | renderItem = function(entry) 91 | return e(Item, { 92 | title = entry.item.title, 93 | }) 94 | end, 95 | keyExtractor = function(entry) 96 | return entry.id 97 | end, 98 | }), 99 | }), 100 | }) 101 | end 102 | 103 | local root = ReactRoblox.createRoot(Instance.new("Folder")) 104 | root:render(ReactRoblox.createPortal(e(App), Players.LocalPlayer.PlayerGui)) 105 | ``` 106 | 107 | ## Documentation 108 | 109 | These components are directly ported from React Native 0.68, so most documentation and articles should apply (modulo Lua syntax). More information on the provided list components can be found at: 110 | 111 | - `VirtualizedList` - https://reactnative.dev/docs/virtualizedlist 112 | - `FlatList` - https://reactnative.dev/docs/flatlist 113 | - `SectionList` - https://reactnative.dev/docs/sectionlist 114 | 115 | ## TODO 116 | 117 | - Add unit tests from upstream React Native 118 | - Use `darklua` bundler to allow more files to easily run outside of Roblox 119 | - Add performance benchmarks 120 | -------------------------------------------------------------------------------- /aftman.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | rojo = "UpliftGames/rojo@7.3.0-uplift.11" 3 | wally = "UpliftGames/wally@0.3.2" 4 | stylua = "johnnymorganz/stylua@0.17.1" 5 | luau-lsp = "johnnymorganz/luau-lsp@1.20.2" 6 | selene = "Kampfkarren/selene@0.25.0" 7 | wally-package-types = "johnnymorganz/wally-package-types@1.2.1" 8 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "virtualized-list-lua", 3 | "tree": { 4 | "$path": "src/" 5 | } 6 | } -------------------------------------------------------------------------------- /dev.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "virtualized-list-lua", 3 | "tree": { 4 | "$className": "DataModel", 5 | "ReplicatedStorage": { 6 | "$className": "Folder", 7 | "Packages": { 8 | "$className": "Folder", 9 | "VirtualizedList": { 10 | "$path": "src/" 11 | }, 12 | "_Index": { 13 | "$path": "Packages/_Index" 14 | }, 15 | "LuauPolyfill": { 16 | "$path": "Packages/LuauPolyfill.lua" 17 | }, 18 | "Flipper": { 19 | "$path": "Packages/Flipper.lua" 20 | }, 21 | "Promise": { 22 | "$path": "Packages/Promise.lua" 23 | }, 24 | "React": { 25 | "$path": "Packages/React.lua" 26 | } 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" 2 | 3 | [lints] 4 | # _G is used for global development feature flags 5 | global_usage = "allow" 6 | shadowing = "allow" 7 | 8 | # TODO: Actually fix unused varaibles. This is just a bandaid. 9 | unused_variable = "allow" 10 | -------------------------------------------------------------------------------- /src/Components/ScrollView/ScrollContentViewNativeComponent.luau: -------------------------------------------------------------------------------- 1 | --[[ 2 | * Copyright (c) Roblox Corporation. All rights reserved. 3 | * Licensed under the MIT License (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * https://opensource.org/licenses/MIT 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | ]] 15 | local srcWorkspace = script.Parent.Parent.Parent 16 | local Packages = srcWorkspace.Parent 17 | local LuauPolyfill = require(Packages.LuauPolyfill) 18 | local Array = LuauPolyfill.Array 19 | local Object = LuauPolyfill.Object 20 | type Object = LuauPolyfill.Object 21 | 22 | local React = require(Packages.React) 23 | local Change = React.Change 24 | 25 | local ScrollContentViewNativeComponent = React.Component:extend("ScrollContentViewNativeComponent") 26 | 27 | function ScrollContentViewNativeComponent:init(props) 28 | self.props = props 29 | end 30 | 31 | function ScrollContentViewNativeComponent:render() 32 | local styleProps = if Array.isArray(self.props.style) 33 | then Array.reduce(self.props.style, function(obj: Object, prop) 34 | return Object.assign(obj, prop) 35 | end, {}) 36 | else self.props.style 37 | 38 | local nativeProps = Object.assign({ 39 | Name = "RCTScrollContentView", 40 | [Change.AbsoluteSize] = self.props.onLayout, 41 | Size = UDim2.new(1, 0, 1, 0), 42 | }, if self.props.AutomaticSize then { AutomaticSize = self.props.AutomaticSize } else nil, styleProps) 43 | 44 | return React.createElement("Frame", nativeProps, self.props.children) 45 | end 46 | 47 | return ScrollContentViewNativeComponent 48 | -------------------------------------------------------------------------------- /src/Components/ScrollView/ScrollViewCommands.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Components/ScrollView/ScrollViewCommands.js 2 | --[[ 3 | Copyright (c) Meta Platforms, Inc. and affiliates. 4 | 5 | This source code is licensed under the MIT license found in the 6 | LICENSE file in the root directory of this source tree. 7 | 8 | @format 9 | @flow strict-local 10 | ]] 11 | local srcWorkspace = script.Parent.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | 15 | type Object = LuauPolyfill.Object 16 | 17 | -- ROBLOX deviation: inline implementation 18 | -- local codegenNativeCommands = require(script.Parent.Parent.Parent.Utilities.codegenNativeCommands).default 19 | local React = require(Packages.React) 20 | type React_ElementRef = React.ElementRef 21 | 22 | local exports = {} 23 | 24 | -- ROBLOX deviation: type Double as number 25 | type Double = number 26 | 27 | -- ROBLOX TODO: add better, proper type 28 | type ScrollViewNativeComponentType = Object 29 | 30 | type NativeCommands = { 31 | flashScrollIndicators: (viewRef: React_ElementRef) -> (), 32 | scrollTo: (viewRef: React_ElementRef, x: Double, y: Double, animated: boolean) -> (), 33 | scrollToEnd: (viewRef: React_ElementRef, animated: boolean) -> (), 34 | zoomToRect: ( 35 | viewRef: React_ElementRef, 36 | rect: { 37 | x: Double, 38 | y: Double, 39 | width: Double, 40 | height: Double, 41 | animated: boolean?, 42 | }, 43 | animated: boolean? 44 | ) -> (), 45 | } 46 | 47 | --[[ ROBLOX deviation: inline implementation 48 | originally: 49 | -- codegenNativeCommands({ 50 | -- supportedCommands = { 51 | -- "flashScrollIndicators", 52 | -- "scrollTo", 53 | -- "scrollToEnd", 54 | -- "zoomToRect", 55 | -- }, 56 | -- }) 57 | ]] 58 | exports.default = { 59 | flashScrollIndicators = function(viewRef: React_ElementRef) 60 | warn("flashScrollIndicators not implemented") 61 | end, 62 | scrollTo = function( 63 | viewRef: React_ElementRef, 64 | x: number, 65 | y: number, 66 | animated: boolean 67 | ) 68 | if animated then 69 | viewRef._nativeRef.animateScrollTo(x, y) 70 | else 71 | viewRef._nativeRef.current.CanvasPosition = Vector2.new(x, y) 72 | end 73 | end, 74 | scrollToEnd = function(viewRef: React_ElementRef, animated: boolean) 75 | local x = if viewRef._nativeRef.current.ScrollingDirection == Enum.ScrollingDirection.Y 76 | then 0 77 | else viewRef._nativeRef.current.AbsoluteCanvasSize.X 78 | local y = if viewRef._nativeRef.current.ScrollingDirection == Enum.ScrollingDirection.Y 79 | then viewRef._nativeRef.current.AbsoluteCanvasSize.Y 80 | else 0 81 | 82 | if animated then 83 | viewRef._nativeRef.animateScrollTo(x, y) 84 | else 85 | viewRef._nativeRef.current.CanvasPosition = Vector2.new(x, y) 86 | end 87 | end, 88 | zoomToRect = function( 89 | viewRef: React_ElementRef, 90 | rect: { 91 | x: Double, 92 | y: Double, 93 | width: Double, 94 | height: Double, 95 | animated: boolean?, 96 | }, 97 | animated: boolean? 98 | ) 99 | warn("zoomToRect not implemented") 100 | end, 101 | } :: NativeCommands 102 | 103 | return exports 104 | -------------------------------------------------------------------------------- /src/Components/ScrollView/ScrollViewContext.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Components/ScrollView/ScrollViewContext.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow strict 9 | * @format 10 | ]] 11 | local srcWorkspace = script.Parent.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | 14 | local LuauPolyfill = require(Packages.LuauPolyfill) 15 | local Object = LuauPolyfill.Object 16 | 17 | local React = require(Packages.React) 18 | type React_Context = React.Context 19 | local exports = {} 20 | 21 | type Value = { horizontal: boolean } | nil 22 | 23 | -- ROBLOX FIXME Luau: should accept nil as valid. Cannot cast 'ReactContext' into 'React_Context<{| horizontal: boolean |}?> 24 | local ScrollViewContext: React_Context = React.createContext(nil :: Value) 25 | if _G.__DEV__ then 26 | ScrollViewContext.displayName = "ScrollViewContext" 27 | end 28 | exports.default = ScrollViewContext 29 | 30 | local HORIZONTAL: Value = Object.freeze({ horizontal = true }) 31 | exports.HORIZONTAL = HORIZONTAL 32 | 33 | local VERTICAL: Value = Object.freeze({ horizontal = false }) 34 | exports.VERTICAL = VERTICAL 35 | 36 | return exports 37 | -------------------------------------------------------------------------------- /src/Components/ScrollView/ScrollViewNativeComponent.luau: -------------------------------------------------------------------------------- 1 | --[[ 2 | * Copyright (c) Roblox Corporation. All rights reserved. 3 | * Licensed under the MIT License (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * https://opensource.org/licenses/MIT 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | ]] 15 | local srcWorkspace = script.Parent.Parent.Parent 16 | local Packages = srcWorkspace.Parent 17 | local LuauPolyfill = require(Packages.LuauPolyfill) 18 | local console = LuauPolyfill.console 19 | local Array = LuauPolyfill.Array 20 | local Object = LuauPolyfill.Object 21 | type Object = LuauPolyfill.Object 22 | 23 | local React = require(Packages.React) 24 | local Change = React.Change 25 | local Event = React.Event 26 | 27 | local Flipper = require(Packages.Flipper) 28 | 29 | local DEFAULT_ANIMATION_CONFIG = { 30 | frequency = 1, 31 | dampingRatio = 1, 32 | } 33 | 34 | local ScrollViewNativeComponent = React.Component:extend("ScrollViewNativeComponent") 35 | function ScrollViewNativeComponent:init(props) 36 | self.props = props 37 | self._nativeRef = React.createRef() 38 | self.lastScrollEventTime = 0 39 | 40 | self.animationConfig = if self.props.animationConfig then self.props.animationConfig else DEFAULT_ANIMATION_CONFIG 41 | 42 | self.motor = Flipper.GroupMotor.new({ 43 | x = 0, 44 | y = 0, 45 | }) 46 | 47 | self.motorStepConnection = self.motor:onStep(function(canvasPosition) 48 | self._nativeRef.current.CanvasPosition = Vector2.new(canvasPosition.x, canvasPosition.y) 49 | end) 50 | 51 | self._nativeRef.animateScrollTo = function(x, y) 52 | self:_startAnimatedScroll(x, y) 53 | end 54 | end 55 | 56 | function ScrollViewNativeComponent:_validateAnimatedScrollInputs(x: number, y: number) 57 | if not self._nativeRef.current then 58 | console.error("scrollTo animation failed: ScrollViewNativeComponent._nativeRef instance is undefined") 59 | end 60 | 61 | if self._nativeRef.current.ScrollingDirection == Enum.ScrollingDirection.X then 62 | if x > self._nativeRef.current.AbsoluteCanvasSize.X then 63 | console.warn( 64 | "scrollTo animation goal out of bounds, setting X goal to: " 65 | .. tostring( 66 | self._nativeRef.current.AbsoluteCanvasSize.X - self._nativeRef.current.AbsoluteWindowSize.X 67 | ) 68 | ) 69 | elseif x < 0 then 70 | console.warn("scrollTo animation goal out of bounds, setting X goal to: 0") 71 | end 72 | else 73 | if y > self._nativeRef.current.AbsoluteCanvasSize.Y then 74 | console.warn( 75 | "scrollTo animation goal out of bounds, setting Y goal to: " 76 | .. tostring( 77 | self._nativeRef.current.AbsoluteCanvasSize.Y - self._nativeRef.current.AbsoluteWindowSize.Y 78 | ) 79 | ) 80 | elseif y < 0 then 81 | console.warn("scrollTo animation goal out of bounds, setting Y goal to: 0") 82 | end 83 | end 84 | end 85 | 86 | function ScrollViewNativeComponent:_startAnimatedScroll(x, y) 87 | if _G.__DEV__ then 88 | self:_validateAnimatedScrollInputs(x, y) 89 | end 90 | local currentCanvasPosition = self._nativeRef.current.CanvasPosition 91 | 92 | self.motor:setGoal({ 93 | x = Flipper.Instant.new(currentCanvasPosition.X), 94 | y = Flipper.Instant.new(currentCanvasPosition.Y), 95 | }) 96 | 97 | --[[ 98 | ROBLOX NOTE: Immediately step to the current canvas position. It can take a frame for the 99 | motor to step to the instant goal if we wait for the heartbeat to call the onStep method. 100 | ]] 101 | self.motor:step(0) 102 | 103 | self.motor:setGoal({ 104 | x = Flipper.Spring.new(x, self.animationConfig), 105 | y = Flipper.Spring.new(y, self.animationConfig), 106 | }) 107 | end 108 | 109 | function ScrollViewNativeComponent:render() 110 | local styleProps = if Array.isArray(self.props.style) 111 | then Array.reduce(self.props.style, function(obj: Object, prop) 112 | return Object.assign(obj, prop) 113 | end, {}) 114 | else self.props.style 115 | 116 | local nativeProps = Object.assign({ 117 | Name = "RCTScrollView", 118 | ScrollingEnabled = if self.props.scrollEnabled ~= nil then self.props.scrollEnabled else true, 119 | Size = UDim2.new(1, 0, 1, 0), 120 | -- ROBLOX DEVIATION: For inverted scrolling, manually override the CanvasSize and CanvasPosition props 121 | CanvasPosition = self.props.CanvasPosition, 122 | CanvasSize = self.props.CanvasSize or UDim2.new(0, 0, 0, 0), 123 | AutomaticCanvasSize = self.props.AutomaticCanvasSize or Enum.AutomaticSize.XY, 124 | ScrollBarThickness = if (self.props.horizontal and self.props.showsHorizontalScrollIndicator == false) 125 | or (not self.props.horizontal and self.props.showsVerticalScrollIndicator == false) 126 | then 0 127 | else nil, 128 | ref = self._nativeRef, 129 | [Change.AbsoluteWindowSize] = self.props.onLayout, 130 | [Change.CanvasPosition] = function(rbx: ScrollingFrame) 131 | local currentScrollEventTime = os.clock() * 1000 132 | local minScrollEventThrottleDelta = self.props.scrollEventThrottle or 0 133 | if currentScrollEventTime - self.lastScrollEventTime > minScrollEventThrottleDelta then 134 | self.props.onScroll(rbx) 135 | self.lastScrollEventTime = currentScrollEventTime 136 | end 137 | end, 138 | [Event.InputBegan] = function(rbx, input: InputObject) 139 | if 140 | input.UserInputType == Enum.UserInputType.MouseWheel 141 | or input.UserInputType == Enum.UserInputType.Touch 142 | then 143 | if self.motor ~= nil then 144 | self.motor:stop() 145 | end 146 | end 147 | 148 | if input.UserInputType == Enum.UserInputType.Touch then 149 | self.props.onTouchStart(rbx, input) 150 | end 151 | end, 152 | [Event.InputEnded] = function(rbx, input: InputObject) 153 | if input.UserInputType == Enum.UserInputType.Touch then 154 | self.props.onTouchEnd(rbx, input) 155 | end 156 | end, 157 | [Event.InputChanged] = function(rbx, input: InputObject) 158 | if input.UserInputType == Enum.UserInputType.Touch then 159 | self.props.onTouchMove(rbx, input) 160 | end 161 | end, 162 | }, styleProps) 163 | 164 | return React.createElement("ScrollingFrame", nativeProps, { 165 | -- ROBLOX DEVIATION: For inverted scrolling, align the ScrollContentView to the bottom 166 | Layout = if self.props.inverted and not self.props.getItemLayout 167 | then React.createElement("UIListLayout", { 168 | HorizontalAlignment = if self.props.horizontal 169 | then Enum.HorizontalAlignment.Right 170 | else Enum.HorizontalAlignment.Center, 171 | VerticalAlignment = if self.props.horizontal 172 | then Enum.VerticalAlignment.Center 173 | else Enum.VerticalAlignment.Bottom, 174 | }) 175 | else nil, 176 | [1] = self.props.children, 177 | }) 178 | end 179 | 180 | function ScrollViewNativeComponent:willUnmount() 181 | if self.motor ~= nil then 182 | self.motor:destroy() 183 | end 184 | 185 | if self.motorStepConnection ~= nil then 186 | self.motorStepConnection:disconnect() 187 | end 188 | end 189 | 190 | return ScrollViewNativeComponent 191 | -------------------------------------------------------------------------------- /src/Components/ScrollView/ScrollViewStickyHeader.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Components/ScrollView/ScrollViewStickyHeader.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow 9 | * @format 10 | ]] 11 | local srcWorkspace = script.Parent.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | local Boolean = LuauPolyfill.Boolean 15 | local clearTimeout = LuauPolyfill.clearTimeout 16 | local setTimeout = LuauPolyfill.setTimeout 17 | type Array = LuauPolyfill.Array 18 | type Object = LuauPolyfill.Object 19 | 20 | -- ROBLOX deviaton: missing types 21 | type ReadOnly = T 22 | 23 | -- local AnimatedImplementation = require(script.Parent.Parent.Parent.Animated.AnimatedImplementation).default 24 | type AnimatedImplementation_Interpolation = any 25 | type AnimatedImplementation_Value = any 26 | -- ROBLOX deviation START: Mocking missing module 27 | -- local AnimatedAddition = require(script.Parent.Parent.Parent.Animated.nodes.AnimatedAddition).default 28 | local AnimatedAddition = { 29 | new = function(...) 30 | return nil 31 | end, 32 | } 33 | -- local AnimatedDiffClamp = require(script.Parent.Parent.Parent.Animated.nodes.AnimatedDiffClamp).default 34 | local AnimatedDiffClamp = { 35 | new = function(...) 36 | return nil 37 | end, 38 | } 39 | -- ROBLOX deviation END 40 | type AnimatedDiffClamp = any 41 | -- local AnimatedNode = require(script.Parent.Parent.Parent.Animated.nodes.AnimatedNode).default 42 | type AnimatedNode = any 43 | local React = require(Packages.React) 44 | type React_Element = React.Element 45 | type React_Node = React.Node 46 | -- ROBLOX deviation: using partial implementation 47 | local StyleSheet = require(srcWorkspace.StyleSheet.StyleSheet) 48 | -- ROBLOX deciation START: mocking missing deps 49 | -- local View = "Frame" 50 | -- local Platform = require(script.Parent.Parent.Parent.Utilities.Platform).default 51 | local Platform = { 52 | OS = "roblox", 53 | } 54 | -- local CoreEventTypesModule = require(script.Parent.Parent.Parent.Types.CoreEventTypes) 55 | -- type LayoutEvent = CoreEventTypesModule.LayoutEvent 56 | type LayoutEvent = any 57 | -- local AnimatedView = AnimatedImplementation:createAnimatedComponent(View) 58 | local AnimatedView = "AnimatedView" 59 | -- ROBLOX deviation END 60 | 61 | -- ROBLOX deviation: predefine variables 62 | local styles 63 | 64 | export type Props = ReadOnly<{ 65 | children: React_Element?, 66 | nextHeaderLayoutY: number?, 67 | onLayout: (event: LayoutEvent) -> (), 68 | scrollAnimatedValue: AnimatedImplementation_Value, 69 | -- Will cause sticky headers to stick at the bottom of the ScrollView instead 70 | -- of the top. 71 | inverted: boolean?, 72 | -- The height of the parent ScrollView. Currently only set when inverted. 73 | scrollViewHeight: number?, 74 | nativeID: string?, 75 | hiddenOnScroll: boolean?, 76 | }> 77 | 78 | type State = Object & { 79 | measured: boolean, 80 | layoutY: number, 81 | layoutHeight: number, 82 | nextHeaderLayoutY: number?, 83 | translateY: number?, 84 | } 85 | 86 | type ScrollViewStickyHeader = { 87 | state: any, 88 | _translateY: any, 89 | _shouldRecreateTranslateY: any, 90 | _haveReceivedInitialZeroTranslateY: any, 91 | _ref: any, 92 | _timer: any, 93 | _animatedValueListenerId: any, 94 | _animatedValueListener: any, 95 | _debounceTimeout: any, 96 | setNextHeaderY: any, 97 | componentWillUnmount: any, 98 | UNSAFE_componentWillReceiveProps: any, 99 | updateTranslateListener: any, 100 | _onLayout: any, 101 | _setComponentRef: any, 102 | render: any, 103 | } --[[ ROBLOX TODO: replace 'any' type/ add missing ]] 104 | 105 | local ScrollViewStickyHeader = React.Component:extend("ScrollViewStickyHeader") 106 | function ScrollViewStickyHeader:init(props) 107 | warn("ScrollViewStickyHeader not fully implemented") 108 | self.props = props 109 | self.state = { 110 | measured = false, 111 | layoutY = 0, 112 | layoutHeight = 0, 113 | nextHeaderLayoutY = self.props.nextHeaderLayoutY, 114 | translateY = nil, 115 | } :: State 116 | 117 | self._translateY = nil :: AnimatedNode? 118 | self._shouldRecreateTranslateY = true 119 | self._haveReceivedInitialZeroTranslateY = true 120 | -- self._ref: any -- TODO T53738161: flow type this, and the whole file 121 | 122 | -- Fabric-only: 123 | -- self._timer: TimeoutID?; 124 | -- self._animatedValueListenerId: string; 125 | -- self._animatedValueListener: (valueObject: ReadOnly<{value: number}>) -> () 126 | self._debounceTimeout = if Platform.OS == "android" then 15 else 64 127 | 128 | self.setNextHeaderY = function(_self, y: number): () 129 | self._shouldRecreateTranslateY = true 130 | self.setState({ nextHeaderLayoutY = y }) 131 | end 132 | 133 | self._onLayout = function(_self, event: any) 134 | local layoutY = event.nativeEvent.layout.y 135 | local layoutHeight = event.nativeEvent.layout.height 136 | local measured = true 137 | if 138 | layoutY ~= self.state.layoutY 139 | or layoutHeight ~= self.state.layoutHeight 140 | or measured ~= self.state.measured 141 | then 142 | self._shouldRecreateTranslateY = true 143 | end 144 | self:setState({ measured = measured, layoutY = layoutY, layoutHeight = layoutHeight }) 145 | self.props:onLayout(event) 146 | local child = React.Children.only(self.props.children) 147 | if child.props.onLayout then 148 | child.props:onLayout(event) 149 | end 150 | end 151 | 152 | self._setComponentRef = function(_self, ref) 153 | self._ref = ref 154 | end 155 | end 156 | 157 | function ScrollViewStickyHeader:componentWillUnmount() 158 | if self._translateY ~= nil and self._animatedValueListenerId ~= nil then 159 | self._translateY:removeListener(self._animatedValueListenerId) 160 | end 161 | if Boolean.toJSBoolean(self._timer) then 162 | clearTimeout(self._timer) 163 | end 164 | end 165 | 166 | function ScrollViewStickyHeader:UNSAFE_componentWillReceiveProps(nextProps: Props) 167 | if 168 | nextProps.scrollViewHeight ~= self.props.scrollViewHeight 169 | or nextProps.scrollAnimatedValue ~= self.props.scrollAnimatedValue 170 | or nextProps.inverted ~= self.props.inverted 171 | then 172 | self._shouldRecreateTranslateY = true 173 | end 174 | end 175 | 176 | function ScrollViewStickyHeader:updateTranslateListener( 177 | translateY: AnimatedImplementation_Interpolation, 178 | isFabric: boolean, 179 | offset: AnimatedDiffClamp? 180 | ) 181 | if self._translateY ~= nil and self._animatedValueListenerId ~= nil then 182 | self._translateY:removeListener(self._animatedValueListenerId) 183 | end 184 | self.translateY = if Boolean.toJSBoolean(offset) then AnimatedAddition.new(translateY, offset) else translateY 185 | 186 | self._shouldRecreateTranslateY = false 187 | 188 | if not isFabric then 189 | return 190 | end 191 | 192 | if not self._animatedValueListener then 193 | -- This is called whenever the (Interpolated) Animated Value 194 | -- updates, which is several times per frame during scrolling. 195 | -- To ensure that the Fabric ShadowTree has the most recent 196 | -- translate style of this node, we debounce the value and then 197 | -- pass it through to the underlying node during render. 198 | -- This is: 199 | -- 1. Only an issue in Fabric. 200 | -- 2. Worse in Android than iOS. In Android, but not iOS, you 201 | -- can touch and move your finger slightly and still trigger 202 | -- a "tap" event. In iOS, moving will cancel the tap in 203 | -- both Fabric and non-Fabric. On Android when you move 204 | -- your finger, the hit-detection moves from the Android 205 | -- platform to JS, so we need the ShadowTree to have knowledge 206 | -- of the current position. 207 | self._animatedValueListener = function(ref) 208 | local value = ref.value 209 | -- When the AnimatedInterpolation is recreated, it always initializes 210 | -- to a value of zero and emits a value change of 0 to its listeners. 211 | if value == 0 and not self._haveReceivedInitialZeroTranslateY then 212 | self._haveReceivedInitialZeroTranslateY = true 213 | return 214 | end 215 | if Boolean.toJSBoolean(self._timer) then 216 | clearTimeout(self._timer) 217 | end 218 | self._timer = setTimeout(function() 219 | if value ~= self.state.translateY then 220 | self:setState({ translateY = value }) 221 | end 222 | end, self._debounceTimeout) 223 | end 224 | end 225 | if self.state.translateY ~= 0 and self.state.translateY ~= nil then 226 | self._haveReceivedInitialZeroTranslateY = false 227 | end 228 | self._animatedValueListenerId = translateY:addListener(self._animatedValueListener) 229 | end 230 | 231 | function ScrollViewStickyHeader:render(): React_Node 232 | -- Fabric Detection 233 | local isFabric = self._ref 234 | and self._ref["_internalInstanceHandle"] 235 | and self._ref["_internalInstanceHandle"].stateNode 236 | and Boolean.toJSBoolean(self._ref["_internalInstanceHandle"].stateNode.canonical) 237 | 238 | -- Initially and in the case of updated props or layout, we 239 | -- recreate this interpolated value. Otherwise, we do not recreate 240 | -- when there are state changes. 241 | if self._shouldRecreateTranslateY then 242 | local inverted, scrollViewHeight = self.props.inverted, self.props.scrollViewHeight 243 | local measured, layoutHeight, layoutY, nextHeaderLayoutY = 244 | self.state.measured, self.state.layoutHeight, self.state.layoutY, self.state.nextHeaderLayoutY 245 | local inputRange: Array = { -1, 0 } 246 | local outputRange: Array = { 0, 0 } 247 | if measured then 248 | if inverted then 249 | -- The interpolation looks like: 250 | -- - Negative scroll: no translation 251 | -- - `stickStartPoint` is the point at which the header will start sticking. 252 | -- It is calculated using the ScrollView viewport height so it is a the bottom. 253 | -- - Headers that are in the initial viewport will never stick, `stickStartPoint` 254 | -- will be negative. 255 | -- - From 0 to `stickStartPoint` no translation. This will cause the header 256 | -- to scroll normally until it reaches the top of the scroll view. 257 | -- - From `stickStartPoint` to when the next header y hits the bottom edge of the header: translate 258 | -- equally to scroll. This will cause the header to stay at the top of the scroll view. 259 | -- - Past the collision with the next header y: no more translation. This will cause the 260 | -- header to continue scrolling up and make room for the next sticky header. 261 | -- In the case that there is no next header just translate equally to 262 | -- scroll indefinitely. 263 | if scrollViewHeight ~= nil then 264 | local stickStartPoint = layoutY + layoutHeight - scrollViewHeight 265 | if stickStartPoint > 0 then 266 | table.insert(inputRange, stickStartPoint) 267 | table.insert(outputRange, 0) 268 | table.insert(inputRange, stickStartPoint + 1) 269 | table.insert(outputRange, 1) 270 | -- If the next sticky header has not loaded yet (probably windowing) or is the last 271 | -- we can just keep it sticked forever. 272 | local collisionPoint = (nextHeaderLayoutY or 0) - layoutHeight - scrollViewHeight 273 | if collisionPoint > stickStartPoint then 274 | table.insert(inputRange, collisionPoint) 275 | table.insert(inputRange, collisionPoint + 1) 276 | table.insert(outputRange, collisionPoint - stickStartPoint) 277 | table.insert(outputRange, collisionPoint - stickStartPoint) 278 | end 279 | end 280 | end 281 | else 282 | -- The interpolation looks like: 283 | -- - Negative scroll: no translation 284 | -- - From 0 to the y of the header: no translation. This will cause the header 285 | -- to scroll normally until it reaches the top of the scroll view. 286 | -- - From header y to when the next header y hits the bottom edge of the header: translate 287 | -- equally to scroll. This will cause the header to stay at the top of the scroll view. 288 | -- - Past the collision with the next header y: no more translation. This will cause the 289 | -- header to continue scrolling up and make room for the next sticky header. 290 | -- In the case that there is no next header just translate equally to 291 | -- scroll indefinitely. 292 | table.insert(inputRange, layoutY) 293 | table.insert(outputRange, 0) 294 | -- If the next sticky header has not loaded yet (probably windowing) or is the last 295 | -- we can just keep it sticked forever. 296 | local collisionPoint = (nextHeaderLayoutY or 0) - layoutHeight 297 | if collisionPoint >= layoutY then 298 | table.insert(inputRange, collisionPoint) 299 | table.insert(inputRange, collisionPoint + 1) 300 | table.insert(outputRange, collisionPoint - layoutY) 301 | table.insert(outputRange, collisionPoint - layoutY) 302 | else 303 | table.insert(inputRange, layoutY + 1) 304 | table.insert(outputRange, 1) 305 | end 306 | end 307 | end 308 | 309 | self:updateTranslateListener( 310 | self.props.scrollAnimatedValue:interpolate({ inputRange = inputRange, outputRange = outputRange }), 311 | isFabric, 312 | if self.props.hiddenOnScroll 313 | then AnimatedDiffClamp.new( 314 | self.props.scrollAnimatedValue 315 | :interpolate({ 316 | extrapolateLeft = "clamp", 317 | inputRange = { layoutY, layoutY + 1 }, 318 | outputRange = { 0, 1 } :: Array, 319 | }) 320 | :interpolate({ inputRange = { 0, 1 }, outputRange = { 0, -1 } :: Array }), 321 | -self.state.layoutHeight, 322 | 0 323 | ) 324 | else nil 325 | ) 326 | end 327 | 328 | local child = React.Children.only(self.props.children) 329 | 330 | -- TODO T68319535: remove this if NativeAnimated is rewritten for Fabric 331 | local passthroughAnimatedPropExplicitValues = if isFabric and self.state.translateY ~= nil 332 | then { style = { transform = { { translateY = self.state.translateY } } } } 333 | else nil 334 | 335 | return React.createElement( 336 | AnimatedView, 337 | { 338 | collapsable = false, 339 | nativeID = self.props.nativeID, 340 | onLayout = self._onLayout, 341 | ref = self._setComponentRef, 342 | style = { 343 | child.props.style, 344 | styles.header, 345 | { 346 | transform = { 347 | { translateY = self._translateY }, 348 | }, 349 | }, 350 | }, 351 | passthroughAnimatedPropExplicitValues = passthroughAnimatedPropExplicitValues, 352 | }, 353 | React.cloneElement(child, { 354 | style = styles.fill, -- We transfer the child style to the wrapper. 355 | onLayout = nil, -- we call this manually through our this._onLayout 356 | }) 357 | ) 358 | end 359 | 360 | styles = StyleSheet.create({ header = { zIndex = 10, position = "relative" }, fill = { flex = 1 } }) 361 | 362 | return ScrollViewStickyHeader 363 | -------------------------------------------------------------------------------- /src/Components/ScrollView/ScrollViewViewConfig.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Components/ScrollView/ScrollViewViewConfig.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow strict-local 9 | * @format 10 | ]] 11 | 12 | -- ROBLOX deviation START: missing module/type 13 | -- local ReactNativeTypesModule = require(script.Parent.Parent.Parent.Renderer.shims.ReactNativeTypes) 14 | -- type PartialViewConfig = ReactNativeTypesModule.PartialViewConfig 15 | type PartialViewConfig = { [string]: any } 16 | -- ROBLOX deviation END 17 | 18 | local ScrollViewViewConfig = { 19 | uiViewClassName = "RCTScrollView", 20 | bubblingEventTypes = {}, 21 | directEventTypes = { topScrollToTop = { registrationName = "onScrollToTop" } }, 22 | validAttributes = { 23 | alwaysBounceHorizontal = true, 24 | alwaysBounceVertical = true, 25 | automaticallyAdjustContentInsets = true, 26 | automaticallyAdjustKeyboardInsets = true, 27 | automaticallyAdjustsScrollIndicatorInsets = true, 28 | bounces = true, 29 | bouncesZoom = true, 30 | canCancelContentTouches = true, 31 | centerContent = true, 32 | -- ROBLOX TODO: investigate if this depenendency is required 33 | -- contentInset = { diff = require("../../Utilities/differ/pointsDiffer") }, 34 | -- ROBLOX TODO: investigate if this depenendency is required 35 | -- contentOffset = { diff = require("../../Utilities/differ/pointsDiffer") }, 36 | contentInsetAdjustmentBehavior = true, 37 | decelerationRate = true, 38 | directionalLockEnabled = true, 39 | disableIntervalMomentum = true, 40 | -- ROBLOX TODO: investigate if this depenendency is required 41 | -- endFillColor = { process = require("../../StyleSheet/processColor") }, 42 | fadingEdgeLength = true, 43 | indicatorStyle = true, 44 | inverted = true, 45 | keyboardDismissMode = true, 46 | maintainVisibleContentPosition = true, 47 | maximumZoomScale = true, 48 | minimumZoomScale = true, 49 | nestedScrollEnabled = true, 50 | onMomentumScrollBegin = true, 51 | onMomentumScrollEnd = true, 52 | onScroll = true, 53 | onScrollBeginDrag = true, 54 | onScrollEndDrag = true, 55 | onScrollToTop = true, 56 | overScrollMode = true, 57 | pagingEnabled = true, 58 | persistentScrollbar = true, 59 | pinchGestureEnabled = true, 60 | scrollEnabled = true, 61 | scrollEventThrottle = true, 62 | -- ROBLOX TODO: investigate if this depenendency is required 63 | -- scrollIndicatorInsets = { diff = require("../../Utilities/differ/pointsDiffer") }, 64 | scrollPerfTag = true, 65 | scrollToOverflowEnabled = true, 66 | scrollsToTop = true, 67 | sendMomentumEvents = true, 68 | showsHorizontalScrollIndicator = true, 69 | showsVerticalScrollIndicator = true, 70 | snapToAlignment = true, 71 | snapToEnd = true, 72 | snapToInterval = true, 73 | snapToOffsets = true, 74 | snapToStart = true, 75 | zoomScale = true, 76 | }, 77 | } 78 | return ScrollViewViewConfig :: PartialViewConfig 79 | -------------------------------------------------------------------------------- /src/Components/ScrollView/processDecelerationRate.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Components/ScrollView/processDecelerationRate.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @format 9 | * 10 | ]] 11 | 12 | --[[ ROBLOX deviation start: We are not using platform in Roblox 13 | upstream: 14 | import Platform from '../../Utilities/Platform'; 15 | ]] 16 | local function processDecelerationRate(decelerationRate: number | "normal" | "fast") 17 | if decelerationRate == "normal" then 18 | --[[ upstream: 19 | return Platform.select({ 20 | ios: 0.998, 21 | android: 0.985, 22 | }); 23 | ]] 24 | return 0.985 25 | elseif decelerationRate == "fast" then 26 | --[[ upstream: 27 | return Platform.select({ 28 | ios: 0.99, 29 | android: 0.9, 30 | }); 31 | ]] 32 | return 0.9 33 | end 34 | 35 | return decelerationRate :: number 36 | end 37 | -- ROBLOX deviation end 38 | 39 | return processDecelerationRate 40 | -------------------------------------------------------------------------------- /src/Components/View/View.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX note: no upstream 2 | --[[ 3 | * Copyright (c) Roblox Corporation. All rights reserved. 4 | * Licensed under the MIT License (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://opensource.org/licenses/MIT 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ]] 16 | -- ROBLOX comment: in the future we may want to replace this with a full implementation of the View component 17 | local Packages = script.Parent.Parent.Parent.Parent 18 | 19 | local LuauPolyfill = require(Packages.LuauPolyfill) 20 | local Array = LuauPolyfill.Array 21 | local Object = LuauPolyfill.Object 22 | type Object = LuauPolyfill.Object 23 | 24 | local React = require(Packages.React) 25 | local Change = React.Change 26 | 27 | local View = React.Component:extend("View") 28 | function View:init(props) 29 | self.props = props 30 | self._nativeRef = self.props.nativeRef or React.createRef() 31 | end 32 | 33 | function View:render() 34 | local styleProps = if Array.isArray(self.props.style) 35 | then Array.reduce(self.props.style, function(obj: Object, prop) 36 | return Object.assign(obj, prop) 37 | end, {}) 38 | else self.props.style 39 | 40 | local props = Object.assign(table.clone(self.props), styleProps) 41 | 42 | self.nativeProps = { 43 | ref = self._nativeRef, 44 | -- ROBLOX TODO: We need to handle all valid Frame props / fail 45 | BackgroundColor3 = props.BackgroundColor3, 46 | Name = props.Name or "View", 47 | Size = props.Size or UDim2.new(1, 0, 0, 0), 48 | AutomaticSize = props.AutomaticSize or Enum.AutomaticSize.Y, 49 | ZIndex = props.ZIndex or nil, 50 | LayoutOrder = props.LayoutOrder or nil, 51 | BorderSizePixel = props.BorderSizePixel or 0, 52 | BackgroundTransparency = props.BackgroundTransparency or 1, 53 | [Change.AbsoluteSize] = props.onLayout, 54 | } 55 | return React.createElement("Frame", self.nativeProps, self.props.children) 56 | end 57 | 58 | return View 59 | -------------------------------------------------------------------------------- /src/Interaction/Batchinator.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Interaction/Batchinator.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow 9 | * @format 10 | ]] 11 | local srcWorkspace = script.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | local setTimeout = LuauPolyfill.setTimeout 15 | local clearTimeout = LuauPolyfill.clearTimeout 16 | 17 | -- ROBLOX FIXME: Mocking InteractionManager, must evaluate if required 18 | -- local InteractionManager = require("./InteractionManager") 19 | local InteractionManager = { 20 | runAfterInteractions = function(_self, fn: () -> ()) 21 | -- ROBLOX comment: "Interactions are not handled by the interaction manager" 22 | fn() 23 | return nil 24 | end, 25 | } 26 | 27 | --[[* 28 | * A simple class for batching up invocations of a low-pri callback. A timeout is set to run the 29 | * callback once after a delay, no matter how many times it's scheduled. Once the delay is reached, 30 | * InteractionManager.runAfterInteractions is used to invoke the callback after any hi-pri 31 | * interactions are done running. 32 | * 33 | * Make sure to cleanup with dispose(). Example: 34 | * 35 | * class Widget extends React.Component { 36 | * _batchedSave: new Batchinator(() => this._saveState, 1000); 37 | * _saveSate() { 38 | * // save this.state to disk 39 | * } 40 | * componentDidUpdate() { 41 | * this._batchedSave.schedule(); 42 | * } 43 | * componentWillUnmount() { 44 | * this._batchedSave.dispose(); 45 | * } 46 | * ... 47 | * } 48 | ]] 49 | 50 | export type Batchinator = { 51 | _callback: () -> (), 52 | _delay: number, 53 | _taskHandle: { cancel: () -> () }?, 54 | dispose: (self: Batchinator, options_: { 55 | abort: boolean, 56 | }?) -> (), 57 | schedule: (self: Batchinator) -> (), 58 | new: (callback: () -> (), delayMS: number) -> Batchinator, 59 | } 60 | 61 | local Batchinator: Batchinator = ({} :: any) :: Batchinator; 62 | (Batchinator :: any).__index = Batchinator 63 | 64 | function Batchinator.new(callback: () -> (), delayMS: number): Batchinator 65 | local self = setmetatable({}, Batchinator) 66 | self._delay = delayMS 67 | self._callback = callback 68 | return self :: any 69 | end 70 | 71 | function Batchinator:dispose(options_: { 72 | abort: boolean, 73 | }?): () 74 | local options = if options_ then options_ else { abort = false } 75 | 76 | if self._taskHandle then 77 | self._taskHandle.cancel() 78 | if not options.abort then 79 | self._callback() 80 | end 81 | self._taskHandle = nil 82 | end 83 | end 84 | 85 | function Batchinator:schedule() 86 | if self._taskHandle then 87 | return 88 | end 89 | 90 | local timeoutHandle = setTimeout(function() 91 | self._taskHandle = InteractionManager:runAfterInteractions(function() 92 | -- Note that we clear the handle before invoking the callback so that if the callback calls 93 | -- schedule again, it will actually schedule another task. 94 | self._taskHandle = nil 95 | self._callback() 96 | end) 97 | end, self._delay) 98 | 99 | self._taskHandle = { 100 | cancel = function() 101 | return clearTimeout(timeoutHandle) 102 | end, 103 | } 104 | end 105 | 106 | return Batchinator 107 | -------------------------------------------------------------------------------- /src/Lists/AnimatedFlatList.luau: -------------------------------------------------------------------------------- 1 | --[[ 2 | * Copyright (c) Roblox Corporation. All rights reserved. 3 | * Licensed under the MIT License (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * https://opensource.org/licenses/MIT 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | ]] 15 | 16 | local srcWorkspace = script.Parent.Parent 17 | local Packages = srcWorkspace.Parent 18 | local React = require(Packages.React) 19 | local LuauPolyfill = require(Packages.LuauPolyfill) 20 | local Object = LuauPolyfill.Object 21 | 22 | local FlatList = require(script.Parent.FlatList) 23 | type FlatListProps = FlatList.Props 24 | 25 | local useFocusNavigationScrolling = require(script.Parent.Hooks).useFocusNavigationScrolling 26 | 27 | type AnimatedProps = { 28 | onSelectedIndexChanged: ((item: ItemT) -> ())?, 29 | viewOffset: number?, 30 | animated: boolean?, 31 | cellRendererKey: string?, 32 | } 33 | 34 | export type Props = AnimatedProps & FlatListProps 35 | 36 | local function AnimatedFlatList(props: Props) 37 | local listRef = React.useRef(nil) 38 | local onAnimationScrollFailed = useFocusNavigationScrolling({ 39 | listRef = listRef, 40 | onSelectedIndexChanged = props.onSelectedIndexChanged, 41 | initialIndex = props.initialScrollIndex, 42 | cellRendererKey = props.cellRendererKey, 43 | viewOffset = props.viewOffset, 44 | animated = props.animated, 45 | data = props.data, 46 | }) 47 | 48 | local flatListProps = Object.assign(table.clone(props), { 49 | viewOffset = Object.None, 50 | animated = Object.None, 51 | onSelectedIndexChanged = Object.None, 52 | ref = listRef, 53 | onScrollToIndexFailed = if props.getItemLayout then nil else onAnimationScrollFailed, 54 | }) 55 | 56 | return React.createElement(FlatList, flatListProps) 57 | end 58 | 59 | return AnimatedFlatList 60 | -------------------------------------------------------------------------------- /src/Lists/CellRenderMask.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Lists/CellRenderMask.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow strict 9 | * @format 10 | ]] 11 | local srcWorkspace = script.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | local Array = LuauPolyfill.Array 15 | local Object = LuauPolyfill.Object 16 | local exports = {} 17 | 18 | type Array = LuauPolyfill.Array 19 | local invariant = require(srcWorkspace.jsUtils.invariant) 20 | export type CellRegion = { 21 | first: number, 22 | last: number, 23 | isSpacer: boolean, 24 | } 25 | export type CellRenderMask = { 26 | _numCells: number, 27 | _regions: Array, 28 | enumerateRegions: (self: CellRenderMask) -> Array, 29 | addCells: (self: CellRenderMask, cells: { first: number, last: number }) -> (), 30 | equals: (self: CellRenderMask, other: CellRenderMask) -> boolean, 31 | _findRegion: (self: CellRenderMask, cellIdx: number) -> (CellRegion?, number?), 32 | } 33 | local CellRenderMask = {} 34 | CellRenderMask.__index = CellRenderMask 35 | 36 | function CellRenderMask.new(numCells: number): CellRenderMask 37 | local self = (setmetatable({}, CellRenderMask) :: any) :: CellRenderMask 38 | invariant(numCells >= 0, "CellRenderMask must contain a non-negative number os cells") 39 | self._numCells = numCells 40 | if numCells == 0 then 41 | self._regions = {} 42 | else 43 | self._regions = { { first = 0, last = numCells - 1, isSpacer = true } } 44 | end 45 | return self 46 | end 47 | function CellRenderMask:enumerateRegions(): Array 48 | return self._regions 49 | end 50 | function CellRenderMask:addCells(cells: { 51 | first: number, 52 | last: number, 53 | }) 54 | invariant( 55 | cells.first >= 0 56 | and cells.first < self._numCells 57 | and cells.last >= 0 58 | and cells.last < self._numCells 59 | and cells.last >= cells.first, 60 | "CellRenderMask.addCells called with invalid cell range" 61 | ) 62 | local firstIntersect: CellRegion, firstIntersectIdx: number = self:_findRegion(cells.first) 63 | local lastIntersect: CellRegion, lastIntersectIdx: number = self:_findRegion(cells.last) 64 | -- Fast-path if the cells to add are already all present in the mask. We 65 | -- will otherwise need to do some mutation. 66 | if firstIntersectIdx == lastIntersectIdx and not firstIntersect.isSpacer then 67 | return 68 | end 69 | -- We need to replace the existing covered regions with 1-3 new regions 70 | -- depending whether we need to split spacers out of overlapping regions. 71 | local newLeadRegion: Array = {} 72 | local newTailRegion: Array = {} 73 | local newMainRegion: CellRegion = Object.assign({}, cells, { isSpacer = false }) 74 | if firstIntersect.first < newMainRegion.first then 75 | if firstIntersect.isSpacer then 76 | table.insert( 77 | newLeadRegion, 78 | { first = firstIntersect.first, last = newMainRegion.first - 1, isSpacer = true } 79 | ) 80 | else 81 | newMainRegion.first = firstIntersect.first 82 | end 83 | end 84 | if lastIntersect.last > newMainRegion.last then 85 | if lastIntersect.isSpacer then 86 | table.insert(newTailRegion, { first = newMainRegion.last + 1, last = lastIntersect.last, isSpacer = true }) 87 | else 88 | newMainRegion.last = lastIntersect.last 89 | end 90 | end 91 | local replacementRegions: Array = Array.concat(newLeadRegion, { newMainRegion }, newTailRegion) 92 | local numRegionsToDelete = lastIntersectIdx - firstIntersectIdx + 1 93 | Array.splice(self._regions, firstIntersectIdx, numRegionsToDelete, table.unpack(replacementRegions)) 94 | end 95 | function CellRenderMask:equals(other: CellRenderMask): boolean 96 | return self._numCells == other._numCells 97 | and #self._regions == #other._regions 98 | and Array.every(self._regions, function(region, i) 99 | return region.first == other._regions[i].first 100 | and region.last == other._regions[i].last 101 | and region.isSpacer == other._regions[i].isSpacer 102 | end) 103 | end 104 | -- ROBLOX DEVIATION: return two values instead of an array of two values to allow better typing 105 | function CellRenderMask:_findRegion(cellIdx: number): (CellRegion?, number?) 106 | local firstIdx = 1 107 | local lastIdx = #self._regions 108 | while firstIdx <= lastIdx do 109 | local middleIdx = math.floor((firstIdx + lastIdx) / 2) 110 | local middleRegion = self._regions[middleIdx] 111 | if cellIdx >= middleRegion.first and cellIdx <= middleRegion.last then 112 | return middleRegion, middleIdx 113 | elseif cellIdx < middleRegion.first then 114 | lastIdx = middleIdx - 1 115 | elseif cellIdx > middleRegion.last then 116 | firstIdx = middleIdx + 1 117 | end 118 | end 119 | invariant(false, string.format("A region was not found containing cellIdx %s", tostring(cellIdx))) 120 | return nil, nil 121 | end 122 | exports.CellRenderMask = CellRenderMask 123 | return exports 124 | -------------------------------------------------------------------------------- /src/Lists/FillRateHelper.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Lists/FillRateHelper.js 2 | --[[** 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow 9 | * @format 10 | *]] 11 | local Packages = script.Parent.Parent.Parent 12 | local LuauPolyfill = require(Packages.LuauPolyfill) 13 | local console = LuauPolyfill.console 14 | local Array = LuauPolyfill.Array 15 | local Object = LuauPolyfill.Object 16 | type Array = LuauPolyfill.Array 17 | 18 | export type FillRateInfo = Info 19 | type Info = { 20 | any_blank_count: number, 21 | any_blank_ms: number, 22 | any_blank_speed_sum: number, 23 | mostly_blank_count: number, 24 | mostly_blank_ms: number, 25 | pixels_blank: number, 26 | pixels_sampled: number, 27 | pixels_scrolled: number, 28 | total_time_spent: number, 29 | sample_count: number, 30 | } 31 | 32 | local Info = {} 33 | Info.__index = Info 34 | function Info.new(): Info 35 | local self = (setmetatable({}, Info) :: any) :: Info 36 | self.any_blank_count = 0 37 | self.any_blank_ms = 0 38 | self.any_blank_speed_sum = 0 39 | self.mostly_blank_count = 0 40 | self.mostly_blank_ms = 0 41 | self.pixels_blank = 0 42 | self.pixels_sampled = 0 43 | self.pixels_scrolled = 0 44 | self.total_time_spent = 0 45 | self.sample_count = 0 46 | return self 47 | end 48 | 49 | type FrameMetrics = { 50 | inLayout: boolean?, 51 | length: number, 52 | offset: number, 53 | } 54 | local DEBUG = false 55 | local _listeners: Array<(Info) -> ()> = {} 56 | local _minSampleCount = 10 57 | local _sampleRate = if DEBUG then 1 else nil 58 | --[[* 59 | * A helper class for detecting when the maximem fill rate of `VirtualizedList` is exceeded. 60 | * By default the sampling rate is set to zero and this will do nothing. If you want to collect 61 | * samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`. 62 | * 63 | * Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with 64 | * `SceneTracker.getActiveScene` to determine the context of the events. 65 | ]] 66 | export type FillRateHelper = { 67 | _anyBlankStartTime: number?, 68 | _enabled: boolean, 69 | _getFrameMetrics: (index: number) -> FrameMetrics?, 70 | _info: Info, 71 | _mostlyBlankStartTime: number?, 72 | _samplesStartTime: number?, 73 | activate: (self: FillRateHelper) -> (), 74 | deactivateAndFlush: (self: FillRateHelper) -> (), 75 | computeBlankness: ( 76 | self: FillRateHelper, 77 | props: { 78 | data: any, 79 | getItemCount: (data: any) -> number, 80 | }, 81 | state: { 82 | first: number, 83 | last: number, 84 | }, 85 | scrollMetrics: { 86 | dOffset: number, 87 | offset: number, 88 | velocity: number, 89 | visibleLength: number, 90 | } 91 | ) -> number, 92 | enabled: (self: FillRateHelper) -> boolean, 93 | _resetData: (self: FillRateHelper) -> (), 94 | } 95 | local FillRateHelper = {} 96 | FillRateHelper.__index = FillRateHelper 97 | 98 | function FillRateHelper.new(getFrameMetrics: (index: number) -> FrameMetrics?): FillRateHelper 99 | local self = (setmetatable({}, FillRateHelper) :: any) :: FillRateHelper 100 | self._anyBlankStartTime = nil 101 | self._enabled = false 102 | self._info = Info.new() 103 | self._mostlyBlankStartTime = nil 104 | self._samplesStartTime = nil 105 | self._getFrameMetrics = getFrameMetrics 106 | if _sampleRate then 107 | self._enabled = _sampleRate > math.random() 108 | else 109 | self._enabled = 0 > math.random() 110 | end 111 | self:_resetData() 112 | return self 113 | end 114 | 115 | function FillRateHelper.addListener(callback: (FillRateInfo) -> ()) 116 | if _sampleRate == nil then 117 | console.warn("Call `FillRateHelper.setSampleRate` before `addListener`.") 118 | end 119 | table.insert(_listeners, callback) 120 | return { 121 | remove = function() 122 | _listeners = Array.filter(_listeners, function(listener) 123 | return callback ~= listener 124 | end) 125 | end, 126 | } 127 | end 128 | 129 | function FillRateHelper.setSampleRate(sampleRate: number) 130 | _sampleRate = sampleRate 131 | end 132 | 133 | function FillRateHelper.setMinSampleCount(minSampleCount: number) 134 | _minSampleCount = minSampleCount 135 | end 136 | 137 | function FillRateHelper:activate() 138 | if self._enabled and self._samplesStartTime == nil then 139 | if DEBUG then 140 | console.debug("FillRateHelper: activate") 141 | end 142 | self._samplesStartTime = os.clock() 143 | end 144 | end 145 | 146 | function FillRateHelper.deactivateAndFlush(self: FillRateHelper) 147 | if not self._enabled then 148 | return 149 | end 150 | local start = self._samplesStartTime -- const for flow 151 | if start == nil then 152 | if DEBUG then 153 | console.debug("FillRateHelper: bail on deactivate with no start time") 154 | end 155 | return 156 | end 157 | if self._info.sample_count < _minSampleCount then 158 | -- Don't bother with under-sampled events. 159 | self:_resetData() 160 | return 161 | end 162 | local total_time_spent = os.clock() - (start :: number) 163 | local info: any = Object.assign({}, self._info, { total_time_spent = total_time_spent }) 164 | if DEBUG then 165 | local derived = { 166 | avg_blankness = self._info.pixels_blank / self._info.pixels_sampled, 167 | avg_speed = self._info.pixels_scrolled / (total_time_spent / 1000), 168 | avg_speed_when_any_blank = self._info.any_blank_speed_sum / self._info.any_blank_count, 169 | any_blank_per_min = self._info.any_blank_count / (total_time_spent / 1000 / 60), 170 | any_blank_time_frac = self._info.any_blank_ms / total_time_spent, 171 | mostly_blank_per_min = self._info.mostly_blank_count / (total_time_spent / 1000 / 60), 172 | mostly_blank_time_frac = self._info.mostly_blank_ms / total_time_spent, 173 | } 174 | for key, _ in pairs(derived) do 175 | derived[key] = math.round(1000 * derived[key]) / 1000 176 | end 177 | console.debug("FillRateHelper deactivateAndFlush: ", { derived = derived, info = info }) 178 | end 179 | Array.forEach(_listeners, function(listener) 180 | return listener(info) 181 | end) 182 | self:_resetData() 183 | end 184 | 185 | function FillRateHelper.computeBlankness( 186 | self: FillRateHelper, 187 | props: { 188 | data: any, 189 | getItemCount: (data: any) -> number, 190 | initialNumToRender: number?, 191 | }, 192 | state: { 193 | first: number, 194 | last: number, 195 | }, 196 | scrollMetrics: { 197 | dOffset: number, 198 | offset: number, 199 | velocity: number, 200 | visibleLength: number, 201 | } 202 | ): number 203 | if not self._enabled or props.getItemCount(props.data) == 0 or self._samplesStartTime == nil then 204 | return 0 205 | end 206 | local dOffset, offset, velocity, visibleLength = 207 | scrollMetrics.dOffset, scrollMetrics.offset, scrollMetrics.velocity, scrollMetrics.visibleLength 208 | 209 | -- Denominator metrics that we track for all events - most of the time there is no blankness and 210 | -- we want to capture that. 211 | self._info.sample_count += 1 212 | self._info.pixels_sampled += math.round(visibleLength) 213 | self._info.pixels_scrolled += math.round(math.abs(dOffset or 0)) 214 | local scrollSpeed = math.round(math.abs(velocity or 0) * 1000) -- px / sec 215 | 216 | -- Whether blank now or not, record the elapsed time blank if we were blank last time. 217 | local now = os.clock() * 1000 218 | if self._anyBlankStartTime ~= nil then 219 | self._info.any_blank_ms += now - self._anyBlankStartTime 220 | end 221 | self._anyBlankStartTime = nil 222 | if self._mostlyBlankStartTime ~= nil then 223 | self._info.mostly_blank_ms += now - self._mostlyBlankStartTime 224 | end 225 | self._mostlyBlankStartTime = nil 226 | 227 | local blankTop = 0 228 | -- ROBLOX FIXME Luau: needs normalization - state.first is known to be of type `number` 229 | local first: number = state.first 230 | local firstFrame = self._getFrameMetrics(first) 231 | while first <= state.last and (not firstFrame or not firstFrame.inLayout) do 232 | firstFrame = self._getFrameMetrics(first) 233 | first += 1 234 | end 235 | -- Only count blankTop if we aren't rendering the first item, otherwise we will count the header 236 | -- as blank. 237 | if firstFrame and first > 1 then 238 | blankTop = math.min(visibleLength, math.max(0, firstFrame.offset - offset)) 239 | end 240 | local blankBottom = 0 241 | -- ROBLOX FIXME Luau: needs normalization - state.last is known to be of type `number` 242 | local last: number = state.last 243 | local lastFrame = self._getFrameMetrics(last) 244 | while last >= state.first and (not lastFrame or not lastFrame.inLayout) do 245 | lastFrame = self._getFrameMetrics(last) 246 | last -= 1 247 | end 248 | -- Only count blankBottom if we aren't rendering the last item, otherwise we will count the 249 | -- footer as blank. 250 | if lastFrame and last < props.getItemCount(props.data) then --ROBLOX deviation: index starts at 1 251 | local bottomEdge = lastFrame.offset + lastFrame.length 252 | blankBottom = math.min(visibleLength, math.max(0, offset + visibleLength - bottomEdge)) 253 | end 254 | local pixels_blank = math.round(blankTop + blankBottom) 255 | local blankness = pixels_blank / visibleLength 256 | if blankness > 0 then 257 | self._anyBlankStartTime = now 258 | self._info.any_blank_speed_sum += scrollSpeed 259 | self._info.any_blank_count += 1 260 | self._info.pixels_blank += pixels_blank 261 | if blankness > 0.5 then 262 | self._mostlyBlankStartTime = now 263 | self._info.mostly_blank_count += 1 264 | end 265 | elseif scrollSpeed < 0.01 or math.abs(dOffset or 0) < 1 then 266 | self:deactivateAndFlush() 267 | end 268 | return blankness 269 | end 270 | 271 | function FillRateHelper:enabled() 272 | return self._enabled 273 | end 274 | 275 | function FillRateHelper:_resetData() 276 | self._anyBlankStartTime = nil 277 | self._info = Info.new() 278 | self._mostlyBlankStartTime = nil 279 | self._samplesStartTime = nil 280 | end 281 | 282 | return FillRateHelper 283 | -------------------------------------------------------------------------------- /src/Lists/FlatList.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Lists/FlatList.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow 9 | * @format 10 | ]] 11 | local srcWorkspace = script.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | local Array = LuauPolyfill.Array 15 | type Array = LuauPolyfill.Array 16 | type ReadonlyArray = Array 17 | local Object = LuauPolyfill.Object 18 | type Object = LuauPolyfill.Object 19 | 20 | type mixed = any 21 | 22 | local Platform = { OS = "roblox" } 23 | local deepDiffer = require(srcWorkspace.Utilities.differ.deepDiffer) 24 | local React = require(Packages.React) 25 | type React_Element_Config = React.ElementConfig 26 | type React_Node = React.React_Node 27 | 28 | local VirtualizedList = require(srcWorkspace.Lists.VirtualizedList) 29 | 30 | local View = require(srcWorkspace.Components.View.View) 31 | 32 | local StyleSheet = require(srcWorkspace.StyleSheet.StyleSheet) 33 | local invariant = require(srcWorkspace.jsUtils.invariant) 34 | 35 | local scrollViewWorkspace = srcWorkspace.Components.ScrollView 36 | 37 | local ScrollView = require(scrollViewWorkspace.ScrollView) 38 | 39 | type ScrollResponderType = ScrollView.ScrollResponderType 40 | 41 | local ScrollViewNativeComponent = require(scrollViewWorkspace.ScrollViewNativeComponent) 42 | type ScrollViewNativeComponent = typeof(ScrollViewNativeComponent) 43 | 44 | -- ROBLOX FIXME: use proper type when available 45 | -- local StyleSheetModule = require(script.Parent.Parent.StyleSheet.StyleSheet) 46 | -- type ViewStyleProp = StyleSheetModule.ViewStyleProp 47 | type ViewStyleProp = Object 48 | 49 | local ViewabilityHelperModule = require(srcWorkspace.Lists.ViewabilityHelper) 50 | type ViewToken = ViewabilityHelperModule.ViewToken 51 | type ViewabilityConfigCallbackPair = ViewabilityHelperModule.ViewabilityConfigCallbackPair 52 | 53 | local VirtualizedListModule = require(srcWorkspace.Lists.VirtualizedList) 54 | type RenderItemType = VirtualizedListModule.RenderItemType 55 | type RenderItemProps = VirtualizedListModule.RenderItemProps 56 | 57 | local defaultKeyExtractor = require(script.Parent.VirtualizeUtils).keyExtractor 58 | type RequiredProps = { 59 | --[[ 60 | For simplicity, data is just a plain array. If you want to use something else, like an immutable list, use the underlying `VirtualizedList` directly. 61 | ]] 62 | data: ReadonlyArray, 63 | } 64 | 65 | type OptionalProps = { 66 | --[[* 67 | * Takes an item from `data` and renders it into the list. Example usage: 68 | * 69 | * ( 71 | * 72 | * )} 73 | * data={[{title: 'Title Text', key: 'item1'}]} 74 | * renderItem={({item, separators}) => ( 75 | * this._onPress(item)} 77 | * onShowUnderlay={separators.highlight} 78 | * onHideUnderlay={separators.unhighlight}> 79 | * 80 | * {item.title} 81 | * 82 | * 83 | * )} 84 | * /> 85 | * 86 | * Provides additional metadata like `index` if you need it, as well as a more generic 87 | * `separators.updateProps` function which let's you set whatever props you want to change the 88 | * rendering of either the leading separator or trailing separator in case the more common 89 | * `highlight` and `unhighlight` (which set the `highlighted: boolean` prop) are insufficient for 90 | * your use-case. 91 | ]] 92 | renderItem: RenderItemType?, 93 | 94 | --[[* 95 | * Optional custom style for multi-item rows generated when numColumns > 1. 96 | ]] 97 | columnWrapperStyle: ViewStyleProp?, 98 | 99 | --[[* 100 | * A marker property for telling the list to re-render (since it implements `PureComponent`). If 101 | * any of your `renderItem`, Header, Footer, etc. functions depend on anything outside of the 102 | * `data` prop, stick it here and treat it immutably. 103 | ]] 104 | extraData: any?, 105 | 106 | --[[* 107 | * `getItemLayout` is an optional optimizations that let us skip measurement of dynamic content if 108 | * you know the height of items a priori. `getItemLayout` is the most efficient, and is easy to 109 | * use if you have fixed height items, for example: 110 | * 111 | * getItemLayout={(data, index) => ( 112 | * {length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index} 113 | * )} 114 | * 115 | * Adding `getItemLayout` can be a great performance boost for lists of several hundred items. 116 | * Remember to include separator length (height or width) in your offset calculation if you 117 | * specify `ItemSeparatorComponent`. 118 | ]] 119 | getItemLayout: ( 120 | data: Array?, 121 | index: number 122 | ) -> { 123 | length: number, 124 | offset: number, 125 | index: number, 126 | }?, 127 | 128 | --[[* 129 | * If true, renders items next to each other horizontally instead of stacked vertically. 130 | ]] 131 | horizontal: boolean?, 132 | 133 | --[[* 134 | * How many items to render in the initial batch. This should be enough to fill the screen but not 135 | * much more. Note these items will never be unmounted as part of the windowed rendering in order 136 | * to improve perceived performance of scroll-to-top actions. 137 | ]] 138 | initialNumToRender: number?, 139 | 140 | --[[* 141 | * Instead of starting at the top with the first item, start at `initialScrollIndex`. This 142 | * disables the "scroll to top" optimization that keeps the first `initialNumToRender` items 143 | * always rendered and immediately renders the items starting at this initial index. Requires 144 | * `getItemLayout` to be implemented. 145 | ]] 146 | initialScrollIndex: number?, 147 | 148 | --[[* 149 | * Reverses the direction of scroll. Uses scale transforms of -1. 150 | ]] 151 | inverted: boolean?, 152 | 153 | --[[* 154 | * Used to extract a unique key for a given item at the specified index. Key is used for caching 155 | * and as the react key to track item re-ordering. The default extractor checks `item.key`, then 156 | * falls back to using the index, like React does. 157 | ]] 158 | keyExtractor: ((item: ItemT, index: number) -> string)?, 159 | 160 | --[[* 161 | * Multiple columns can only be rendered with `horizontal={false}` and will zig-zag like a 162 | * `flexWrap` layout. Items should all be the same height - masonry layouts are not supported. 163 | * 164 | * The default value is 1. 165 | ]] 166 | numColumns: number?, 167 | 168 | --[[* 169 | * Note: may have bugs (missing content) in some circumstances - use at your own risk. 170 | * 171 | * This may improve scroll performance for large lists. 172 | * 173 | * The default value is true for Android. 174 | ]] 175 | removeClippedSubviews: boolean?, 176 | 177 | --[[ 178 | * See `ScrollView` for flow type and further documentation. 179 | ]] 180 | fadingEdgeLength: number?, 181 | } 182 | 183 | --[[* 184 | * Default Props Helper Functions 185 | * Use the following helper functions for default values 186 | ]] 187 | -- removeClippedSubviewsOrDefault(this.props.removeClippedSubviews) 188 | local function removeClippedSubviewsOrDefault(removeClippedSubviews: boolean?) 189 | return if removeClippedSubviews ~= nil then removeClippedSubviews else Platform.OS == "android" 190 | end 191 | 192 | -- numColumnsOrDefault(this.props.numColumns) 193 | local function numColumnsOrDefault(numColumns: number?): number 194 | return if numColumns ~= nil then numColumns else 1 195 | end 196 | 197 | type FlatListProps = RequiredProps & OptionalProps 198 | type VirtualizedListProps = React_Element_Config 199 | type Item = any 200 | 201 | -- ROBLOX deviation: Upstream uses flow attributes such as $Diff and $PropertyType 202 | --[[ 203 | { 204 | ...$Diff< 205 | VirtualizedListProps, 206 | { 207 | getItem: $PropertyType, 208 | getItemCount: $PropertyType, 209 | getItemLayout: $PropertyType, 210 | renderItem: $PropertyType, 211 | keyExtractor: $PropertyType, 212 | ... 213 | }, 214 | >, 215 | ...FlatListProps, 216 | ... 217 | }; 218 | ]] 219 | export type Props = VirtualizedListProps & FlatListProps 220 | 221 | export type FlatList = { 222 | props: Props, 223 | scrollToEnd: (self: FlatList, params: { animated: boolean? }?) -> (), 224 | scrollToIndex: ( 225 | self: FlatList, 226 | params: { 227 | animated: boolean?, 228 | index: number, 229 | viewOffset: number?, 230 | viewPosition: number?, 231 | } 232 | ) -> (), 233 | scrollToItem: ( 234 | self: FlatList, 235 | params: { 236 | animated: boolean?, 237 | item: ItemT, 238 | viewPosition: number?, 239 | } 240 | ) -> (), 241 | scrollToOffset: (self: FlatList, params: { animated: boolean?, offset: number }) -> (), 242 | recordInteraction: (self: FlatList) -> (), 243 | flashScrollIndicators: (self: FlatList) -> (), 244 | getScrollResponder: (self: FlatList) -> ScrollResponderType?, 245 | getNativeScrollRef: ( 246 | self: FlatList 247 | ) -> React.ElementRef? | React.ElementRef?, 248 | getScrollableNode: (self: FlatList) -> any, 249 | setNativeProps: (self: FlatList, props: { [string]: mixed }) -> (), 250 | _listRef: React.ElementRef, 251 | _virtualizedListPairs: Array, 252 | _captureRef: (ref: any) -> (), 253 | _checkProps: (self: FlatList, props: Props) -> (), 254 | _getItem: (data: Array, index: number) -> Array | ItemT, 255 | _getItemCount: (data: Array?) -> number, 256 | _keyExtractor: (items: ItemT | Array, index: number) -> string, 257 | _pushMultiColumnViewable: (self: FlatList, arr: Array, v: ViewToken) -> (), 258 | _createOnViewableItemsChanged: ( 259 | self: FlatList, 260 | onViewableItemsChanged: ( 261 | info: { 262 | viewableItems: Array, 263 | changed: Array, 264 | } 265 | ) -> () 266 | ) -> (), 267 | _renderer: () -> { 268 | [string]: (info: RenderItemProps) -> React.Node, 269 | }, 270 | } 271 | 272 | --[[* 273 | * A performant interface for rendering simple, flat lists, supporting the most handy features: 274 | * 275 | * - Fully cross-platform. 276 | * - Optional horizontal mode. 277 | * - Configurable viewability callbacks. 278 | * - Header support. 279 | * - Footer support. 280 | * - Separator support. 281 | * - Pull to Refresh. 282 | * - Scroll loading. 283 | * - ScrollToIndex support. 284 | * 285 | * If you need section support, use [``](docs/sectionlist.html). 286 | * 287 | * Minimal Example: 288 | * 289 | * {item.key}} 292 | * /> 293 | * 294 | * More complex, multi-select example demonstrating `PureComponent` usage for perf optimization and avoiding bugs. 295 | * 296 | * - By binding the `onPressItem` handler, the props will remain `===` and `PureComponent` will 297 | * prevent wasteful re-renders unless the actual `id`, `selected`, or `title` props change, even 298 | * if the components rendered in `MyListItem` did not have such optimizations. 299 | * - By passing `extraData={this.state}` to `FlatList` we make sure `FlatList` itself will re-render 300 | * when the `state.selected` changes. Without setting this prop, `FlatList` would not know it 301 | * needs to re-render any items because it is also a `PureComponent` and the prop comparison will 302 | * not show any changes. 303 | * - `keyExtractor` tells the list to use the `id`s for the react keys instead of the default `key` property. 304 | * 305 | * 306 | * class MyListItem extends React.PureComponent { 307 | * _onPress = () => { 308 | * this.props.onPressItem(this.props.id); 309 | * }; 310 | * 311 | * render() { 312 | * const textColor = this.props.selected ? "red" : "black"; 313 | * return ( 314 | * 315 | * 316 | * 317 | * {this.props.title} 318 | * 319 | * 320 | * 321 | * ); 322 | * } 323 | * } 324 | * 325 | * class MultiSelectList extends React.PureComponent { 326 | * state = {selected: (new Map(): Map)}; 327 | * 328 | * _keyExtractor = (item, index) => item.id; 329 | * 330 | * _onPressItem = (id: string) => { 331 | * // updater functions are preferred for transactional updates 332 | * this.setState((state) => { 333 | * // copy the map rather than modifying state. 334 | * const selected = new Map(state.selected); 335 | * selected.set(id, !selected.get(id)); // toggle 336 | * return {selected}; 337 | * }); 338 | * }; 339 | * 340 | * _renderItem = ({item}) => ( 341 | * 347 | * ); 348 | * 349 | * render() { 350 | * return ( 351 | * 357 | * ); 358 | * } 359 | * } 360 | * 361 | * This is a convenience wrapper around [``](docs/virtualizedlist.html), 362 | * and thus inherits its props (as well as those of `ScrollView`) that aren't explicitly listed 363 | * here, along with the following caveats: 364 | * 365 | * - Internal state is not preserved when content scrolls out of the render window. Make sure all 366 | * your data is captured in the item data or external stores like Flux, Redux, or Relay. 367 | * - This is a `PureComponent` which means that it will not re-render if `props` remain shallow- 368 | * equal. Make sure that everything your `renderItem` function depends on is passed as a prop 369 | * (e.g. `extraData`) that is not `===` after updates, otherwise your UI may not update on 370 | * changes. This includes the `data` prop and parent component state. 371 | * - In order to constrain memory and enable smooth scrolling, content is rendered asynchronously 372 | * offscreen. This means it's possible to scroll faster than the fill rate ands momentarily see 373 | * blank content. This is a tradeoff that can be adjusted to suit the needs of each application, 374 | * and we are working on improving it behind the scenes. 375 | * - By default, the list looks for a `key` prop on each item and uses that for the React key. 376 | * Alternatively, you can provide a custom `keyExtractor` prop. 377 | * 378 | * Also inherits [ScrollView Props](docs/scrollview.html#props), unless it is nested in another FlatList of same orientation. 379 | ]] 380 | local FlatList = React.PureComponent:extend("FlatList") 381 | 382 | function FlatList:init(props: Props) 383 | self._virtualizedListPairs = {} :: Array 384 | self._captureRef = function(ref) 385 | self._listRef = ref 386 | end 387 | 388 | self._getItem = function(data: Array, index: number): (Array | ItemT) 389 | local numColumns = numColumnsOrDefault(self.props.numColumns) 390 | if numColumns > 1 then 391 | local ret = {} 392 | for kk = 1, numColumns do 393 | local item = data[(index - 1) * numColumns + kk] 394 | if item ~= nil then 395 | table.insert(ret, item) 396 | end 397 | end 398 | return ret 399 | else 400 | return data[index] :: ItemT 401 | end 402 | end 403 | 404 | self._getItemCount = function(data: Array?): number 405 | if data ~= nil then 406 | local numColumns = numColumnsOrDefault(self.props.numColumns) 407 | 408 | return if numColumns > 1 then math.ceil(#data / numColumns) else #data 409 | else 410 | return 0 411 | end 412 | end 413 | 414 | self._keyExtractor = function(items: (ItemT | Array), index: number): string? 415 | local numColumns = numColumnsOrDefault(self.props.numColumns) 416 | local keyExtractor = if self.props.keyExtractor ~= nil then self.props.keyExtractor else defaultKeyExtractor 417 | if numColumns > 1 then 418 | if Array.isArray(items) then 419 | return Array.join( 420 | -- ROBLOX FIXME Luau: Unable to infer the type of items as Array, manual casting required 421 | Array.map(items :: Array, function(item, kk) 422 | return keyExtractor( 423 | --[[ ROBLOX deviation: Upstream uses $FlowFixMe to suppress type issues (item: $FlowFixMe): ItemT ]] 424 | item :: ItemT, 425 | (index - 1) * numColumns + kk 426 | ) 427 | end), 428 | ":" 429 | ) 430 | else 431 | invariant( 432 | Array.isArray(items), 433 | "FlatList: Encountered internal consistency error, expected each item to consist of an " 434 | .. "array with 1-%s columns; instead, received a single item.", 435 | numColumns 436 | ) 437 | return nil 438 | end 439 | else 440 | -- $FlowFixMe[incompatible-call] Can't call keyExtractor with an array 441 | return keyExtractor(items, index) 442 | end 443 | end 444 | 445 | self._renderer = function() 446 | local ListItemComponent, renderItem, columnWrapperStyle = 447 | self.props.ListItemComponent, self.props.renderItem, self.props.columnWrapperStyle 448 | 449 | local numColumns = numColumnsOrDefault(self.props.numColumns) 450 | local virtualizedListRenderKey = if ListItemComponent then "ListItemComponent" else "renderItem" 451 | local function renderer(props): React_Node 452 | if ListItemComponent then 453 | -- $FlowFixMe[not-a-component] Component isn't valid 454 | -- $FlowFixMe[incompatible-type-arg] Component isn't valid 455 | -- $FlowFixMe[incompatible-return] Component isn't valid 456 | return React.createElement(ListItemComponent, props) 457 | elseif renderItem then 458 | -- $FlowFixMe[incompatible-call] 459 | return renderItem(props) 460 | else 461 | return nil 462 | end 463 | end 464 | return { 465 | --[[ $FlowFixMe[invalid-computed-prop] (>=0.111.0 site=react_native_fb) 466 | * This comment suppresses an error found when Flow v0.111 was deployed. 467 | * To see the error, delete this comment and run Flow. ]] 468 | [virtualizedListRenderKey] = function(info: RenderItemProps): React_Node 469 | if numColumns > 1 then 470 | -- ROBLOX deviation: Adding this to layout the items depending on whether or not the horizontal prop is true 471 | local UIListLayout = React.createElement("UIListLayout", { 472 | key = if self.props.horizontal then "UIListVerticalLayout" else "UIListHorizontalLayout", 473 | -- Name = if self.props.horizontal then "UIListVerticalLayout" else "UIListHorizontalLayout", 474 | FillDirection = if self.props.horizontal 475 | then Enum.FillDirection.Vertical 476 | else Enum.FillDirection.Horizontal, 477 | SortOrder = Enum.SortOrder.LayoutOrder, 478 | }) 479 | 480 | local item, index = info.item, info.index 481 | 482 | invariant(Array.isArray(item), "Expected array of items with numColumns > 1") 483 | return React.createElement( 484 | View, 485 | -- ROBLOX deviation: We are removing styles.row because we are using FillDirection instead for the same functionality 486 | { style = StyleSheet.compose(columnWrapperStyle) }, 487 | Array.concat( 488 | { 489 | UIListLayout, 490 | } :: any, 491 | Array.map((item :: any) :: Array, function(it, kk) 492 | local element = renderer({ 493 | -- ROBLOX deviation: We are passing down LayoutOrder to ensure correct rendering order of list items 494 | item = Object.assign({}, it, { LayoutOrder = kk }), 495 | index = (index - 1) * numColumns + kk, 496 | separators = info.separators, 497 | }) 498 | return if element ~= nil 499 | then React.createElement(React.Fragment, { key = kk }, element) 500 | else nil 501 | end) 502 | ) 503 | ) 504 | else 505 | return renderer(info) 506 | end 507 | end, 508 | } 509 | end 510 | self:_checkProps(self.props) 511 | if self.props.viewabilityConfigCallbackPairs then 512 | self._virtualizedListPairs = Array.map(self.props.viewabilityConfigCallbackPairs, function(pair) 513 | return { 514 | viewabilityConfig = pair.viewabilityConfig, 515 | onViewableItemsChanged = self:_createOnViewableItemsChanged(pair.onViewableItemsChanged), 516 | } 517 | end) 518 | elseif self.props.onViewableItemsChanged then 519 | table.insert(self._virtualizedListPairs, {--[[ $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This 520 | * comment suppresses an error found when Flow v0.63 was deployed. To 521 | * see the error delete this comment and run Flow. ]] 522 | viewabilityConfig = self.props.viewabilityConfig, 523 | onViewableItemsChanged = self:_createOnViewableItemsChanged(self.props.onViewableItemsChanged), 524 | }) 525 | end 526 | end 527 | --[[ 528 | * Scrolls to the end of the content. May be janky without `getItemLayout` prop. 529 | ]] 530 | function FlatList:scrollToEnd(params: { 531 | animated: boolean?, 532 | }?) 533 | if self._listRef then 534 | self._listRef:scrollToEnd(params) 535 | end 536 | end 537 | 538 | --[[ 539 | * Scrolls to the item at the specified index such that it is positioned in the viewable area 540 | * such that `viewPosition` 0 places it at the top, 1 at the bottom, and 0.5 centered in the 541 | * middle. `viewOffset` is a fixed number of pixels to offset the final target position. 542 | * 543 | * Note: cannot scroll to locations outside the render window without specifying the 544 | * `getItemLayout` prop. 545 | ]] 546 | function FlatList:scrollToIndex(params: { 547 | animated: boolean?, 548 | index: number, 549 | viewOffset: number?, 550 | viewPosition: number?, 551 | }) 552 | if self._listRef then 553 | self._listRef:scrollToIndex(params) 554 | end 555 | end 556 | 557 | --[[ 558 | * Requires linear scan through data - use `scrollToIndex` instead if possible. 559 | * 560 | * Note: cannot scroll to locations outside the render window without specifying the 561 | * `getItemLayout` prop. 562 | ]] 563 | function FlatList:scrollToItem(params: { 564 | animated: boolean?, 565 | item: ItemT, 566 | viewPosition: number?, 567 | }) 568 | if self._listRef then 569 | self._listRef:scrollToItem(params) 570 | end 571 | end 572 | 573 | --[[ 574 | * Scroll to a specific content pixel offset in the list. 575 | * 576 | * Check out [scrollToOffset](docs/virtualizedlist.html#scrolltooffset) of VirtualizedList 577 | ]] 578 | function FlatList:scrollToOffset(params: { 579 | animated: boolean?, 580 | offset: number, 581 | }) 582 | if self._listRef then 583 | self._listRef:scrollToOffset(params) 584 | end 585 | end 586 | 587 | --[[ 588 | * Tells the list an interaction has occurred, which should trigger viewability calculations, e.g. 589 | * if `waitForInteractions` is true and the user has not scrolled. This is typically called by 590 | * taps on items or by navigation actions. 591 | *]] 592 | function FlatList:recordInteraction() 593 | if self._listRef then 594 | self._listRef:recordInteraction() 595 | end 596 | end 597 | 598 | --[[ 599 | * Displays the scroll indicators momentarily. 600 | * 601 | * @platform ios 602 | ]] 603 | function FlatList:flashScrollIndicators() 604 | if self._listRef then 605 | self._listRef:flashScrollIndicators() 606 | end 607 | end 608 | 609 | --[[ 610 | * Provides a handle to the underlying scroll responder. 611 | ]] 612 | function FlatList:getScrollResponder() 613 | if self._listRef then 614 | return self._listRef:getScrollResponder() 615 | end 616 | end 617 | 618 | --[[ 619 | * Provides a reference to the underlying host component 620 | ]] 621 | function FlatList:getNativeScrollRef() 622 | if self._listRef then 623 | --[[ $FlowFixMe[incompatible-return] Suppresses errors found when fixing 624 | * TextInput typing ]] 625 | return self._listRef:getScrollRef() 626 | end 627 | end 628 | function FlatList:getScrollableNode() 629 | if self._listRef then 630 | return self._listRef:getScrollableNode() 631 | end 632 | end 633 | function FlatList:setNativeProps(props: { 634 | [string]: mixed, 635 | }) 636 | if self._listRef then 637 | self._listRef:setNativeProps(props) 638 | end 639 | end 640 | function FlatList:componentDidUpdate(prevProps: Props) 641 | invariant( 642 | prevProps.numColumns == self.props.numColumns, 643 | "Changing numColumns on the fly is not supported. Change the key prop on FlatList when " 644 | .. "changing the number of columns to force a fresh render of the component." 645 | ) 646 | invariant( 647 | prevProps.onViewableItemsChanged == self.props.onViewableItemsChanged, 648 | "Changing onViewableItemsChanged on the fly is not supported" 649 | ) 650 | invariant( 651 | not deepDiffer(prevProps.viewabilityConfig, self.props.viewabilityConfig), 652 | "Changing viewabilityConfig on the fly is not supported" 653 | ) 654 | 655 | invariant( 656 | prevProps.viewabilityConfigCallbackPairs == self.props.viewabilityConfigCallbackPairs, 657 | "Changing viewabilityConfigCallbackPairs on the fly is not supported" 658 | ) 659 | 660 | self:_checkProps(self.props) 661 | end 662 | function FlatList:_checkProps(props: Props) 663 | local getItem, getItemCount, horizontal, columnWrapperStyle, onViewableItemsChanged, viewabilityConfigCallbackPairs = -- $FlowFixMe[prop-missing] this prop doesn't exist, is only used for an invariant 664 | props.getItem, -- $FlowFixMe[prop-missing] this prop doesn't exist, is only used for an invariant 665 | props.getItemCount, 666 | props.horizontal, 667 | props.columnWrapperStyle, 668 | props.onViewableItemsChanged, 669 | props.viewabilityConfigCallbackPairs 670 | local numColumns = numColumnsOrDefault(self.props.numColumns) 671 | invariant(not getItem and not getItemCount, "FlatList does not support custom data formats.") 672 | if numColumns > 1 then 673 | invariant(not horizontal, "numColumns does not support horizontal.") 674 | else 675 | invariant(not columnWrapperStyle, "columnWrapperStyle not supported for single column lists") 676 | end 677 | invariant( 678 | not (onViewableItemsChanged and viewabilityConfigCallbackPairs), 679 | "FlatList does not support setting both onViewableItemsChanged and " .. "viewabilityConfigCallbackPairs." 680 | ) 681 | end 682 | function FlatList:_pushMultiColumnViewable(arr: Array, v: ViewToken): () 683 | local numColumns = numColumnsOrDefault(self.props.numColumns) 684 | local keyExtractor = if self.props.keyExtractor ~= nil then self.props.keyExtractor else defaultKeyExtractor 685 | Array.forEach(v.item, function(item, ii) 686 | invariant(v.index ~= nil, "Missing index!") 687 | local index = (v.index - 1) * numColumns + ii 688 | table.insert(arr, Object.assign({}, v, { item = item, key = keyExtractor(item, index), index = index })) 689 | end) 690 | end 691 | function FlatList:_createOnViewableItemsChanged(onViewableItemsChanged: (( 692 | info: { 693 | viewableItems: Array, 694 | changed: Array, 695 | } 696 | ) -> ())?) 697 | return function(info: { 698 | viewableItems: Array, 699 | changed: Array, 700 | }) 701 | local numColumns = numColumnsOrDefault(self.props.numColumns) 702 | if onViewableItemsChanged ~= nil then 703 | if numColumns > 1 then 704 | local changed = {} 705 | local viewableItems = {} 706 | Array.forEach(info.viewableItems, function(v) 707 | return self:_pushMultiColumnViewable(viewableItems, v) 708 | end) 709 | Array.forEach(info.changed, function(v) 710 | return self:_pushMultiColumnViewable(changed, v) 711 | end) 712 | 713 | onViewableItemsChanged({ viewableItems = viewableItems, changed = changed }) 714 | else 715 | onViewableItemsChanged(info) 716 | end 717 | end 718 | end 719 | end 720 | function FlatList:render(): React_Node 721 | local ref = self.props 722 | 723 | local _numColumns, _columnWrapperStyle, removeClippedSubviews, restProps = 724 | ref.numColumns, ref.columnWrapperStyle, ref.removeClippedSubviews, Object.assign({}, ref, { 725 | numColumns = Object.None, 726 | columnWrapperStyle = Object.None, 727 | removeClippedSubviews = Object.None, 728 | }) 729 | 730 | return React.createElement( 731 | VirtualizedList, 732 | Object.assign({}, restProps, { 733 | getItem = self._getItem, 734 | getItemCount = self._getItemCount, 735 | keyExtractor = self._keyExtractor, 736 | ref = self._captureRef, 737 | viewabilityConfigCallbackPairs = self._virtualizedListPairs, 738 | removeClippedSubviews = removeClippedSubviewsOrDefault(removeClippedSubviews), 739 | }, self._renderer()) 740 | ) 741 | end 742 | 743 | -- ROBLOX deviation: We are not actually using this 744 | -- styles = StyleSheet.create({ row = { flexDirection = "row" } }) 745 | 746 | return FlatList 747 | -------------------------------------------------------------------------------- /src/Lists/Hooks/init.luau: -------------------------------------------------------------------------------- 1 | --[[ 2 | * Copyright (c) Roblox Corporation. All rights reserved. 3 | * Licensed under the MIT License (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * https://opensource.org/licenses/MIT 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | ]] 15 | 16 | return { 17 | useFocusNavigationScrolling = require(script.useFocusNavigationScrolling), 18 | } 19 | -------------------------------------------------------------------------------- /src/Lists/Hooks/useFocusNavigationScrolling.luau: -------------------------------------------------------------------------------- 1 | --[[ 2 | * Copyright (c) Roblox Corporation. All rights reserved. 3 | * Licensed under the MIT License (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * https://opensource.org/licenses/MIT 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | ]] 15 | 16 | local GuiService = game:GetService("GuiService") 17 | 18 | local srcWorkspace = script.Parent.Parent.Parent 19 | local Packages = srcWorkspace.Parent 20 | 21 | local React = require(Packages.React) 22 | 23 | local FlatList = require(script.Parent.Parent.FlatList) 24 | type FlatList = FlatList.FlatList 25 | 26 | type ScrollConfig = { 27 | listRef: React.Ref>, 28 | onSelectedIndexChanged: ((index: number) -> ())?, 29 | initialIndex: number?, 30 | cellRendererKey: string?, 31 | viewOffset: number?, 32 | animated: boolean?, 33 | data: { any }, 34 | } 35 | 36 | type ScrollToIndexFailedInfo = { 37 | index: number, 38 | highestMeasuredFrameIndex: number, 39 | averageItemLength: number, 40 | } 41 | 42 | local function useFocusNavigationScrolling(scrollConfig: ScrollConfig) 43 | local cellRendererKey = if scrollConfig.cellRendererKey then scrollConfig.cellRendererKey else "CellRendererView" 44 | local initialIndex = if scrollConfig.initialIndex then scrollConfig.initialIndex else 1 45 | local listRef = scrollConfig.listRef :: { current: FlatList? } 46 | local onSelectedIndexChanged = scrollConfig.onSelectedIndexChanged 47 | local viewOffset = scrollConfig.viewOffset 48 | local animated = if scrollConfig.animated ~= nil then scrollConfig.animated else true 49 | local focusedIndex = React.useRef(initialIndex) 50 | local data = scrollConfig.data 51 | 52 | local onScrollToIndexFailed = React.useCallback(function(info: ScrollToIndexFailedInfo) 53 | local offsetEstimate = info.index * info.averageItemLength 54 | if listRef and listRef.current then 55 | listRef.current:scrollToOffset({ 56 | offset = offsetEstimate, 57 | }) 58 | else 59 | warn( 60 | "Animated scrolling failed, the ref to the ScrollView is nil." 61 | .. "This could indicate that you are selecting focus before the ScrollView has mounted." 62 | ) 63 | end 64 | end, { listRef }) 65 | 66 | React.useEffect(function() 67 | local function scrollToFocusSelection(changedValue: string) 68 | local selectedCoreObject = nil 69 | if changedValue == "SelectedObject" then 70 | selectedCoreObject = GuiService.SelectedObject 71 | elseif changedValue == "SelectedCoreObject" then 72 | selectedCoreObject = GuiService.SelectedCoreObject 73 | end 74 | 75 | if not selectedCoreObject then 76 | return nil 77 | end 78 | 79 | local parentView = (selectedCoreObject :: GuiObject):FindFirstAncestor(cellRendererKey) :: GuiObject 80 | if not parentView or not parentView.LayoutOrder then 81 | return nil 82 | end 83 | 84 | if 85 | parentView.LayoutOrder == focusedIndex.current 86 | or parentView.LayoutOrder > #data 87 | or parentView.LayoutOrder < 1 88 | then 89 | -- FlatList maps index in data to LayoutOrder. 90 | -- If the index hasn't changed or is out of bounds, we shouldn't animate 91 | return nil 92 | end 93 | 94 | focusedIndex.current = parentView.LayoutOrder 95 | 96 | if onSelectedIndexChanged then 97 | onSelectedIndexChanged(focusedIndex.current :: number) 98 | end 99 | 100 | if not listRef or not listRef.current then 101 | return nil 102 | end 103 | 104 | (listRef.current :: FlatList):scrollToIndex({ 105 | index = focusedIndex.current :: number, 106 | animated = animated, 107 | viewOffset = viewOffset, 108 | }) 109 | return nil 110 | end 111 | 112 | local connection = GuiService.Changed:Connect(scrollToFocusSelection) 113 | 114 | return function() 115 | if connection then 116 | connection:Disconnect() 117 | end 118 | end 119 | end, { listRef, focusedIndex, cellRendererKey, onSelectedIndexChanged, animated, viewOffset } :: { any }) 120 | 121 | return onScrollToIndexFailed 122 | end 123 | 124 | return useFocusNavigationScrolling 125 | -------------------------------------------------------------------------------- /src/Lists/SectionList.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Lists/SectionList.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow 9 | * @format 10 | ]] 11 | 12 | local srcWorkspace = script.Parent.Parent 13 | local Packages = srcWorkspace.Parent 14 | local LuauPolyfill = require(Packages.LuauPolyfill) 15 | local Boolean = LuauPolyfill.Boolean 16 | local Object = LuauPolyfill.Object 17 | type Array = LuauPolyfill.Array 18 | type Object = LuauPolyfill.Object 19 | 20 | -- ROBLOX deviation: missing types 21 | type ReadOnlyArray = Array 22 | 23 | local exports = {} 24 | 25 | local Platform = { 26 | OS = "roblox", 27 | } 28 | local React = require(Packages.React) 29 | type React_Element

= React.ReactElement 30 | type React_ElementRef = React.ElementRef 31 | local VirtualizedSectionList = require(script.Parent.VirtualizedSectionList) 32 | type VirtualizedSectionList = VirtualizedSectionList.VirtualizedSectionList 33 | 34 | local ScrollViewModule = require(srcWorkspace.Components.ScrollView.ScrollView) 35 | type ScrollResponderType = ScrollViewModule.ScrollResponderType 36 | type _SectionBase = VirtualizedSectionList.SectionBase 37 | type VirtualizedSectionListProps = VirtualizedSectionList.Props 38 | type ScrollToLocationParamsType = VirtualizedSectionList.ScrollToLocationParamsType 39 | 40 | type Item = any 41 | 42 | export type SectionBase = _SectionBase 43 | 44 | type RequiredProps = { 45 | 46 | --[[* 47 | * The actual data to render, akin to the `data` prop in [``](https://reactnative.dev/docs/flatlist). 48 | * 49 | * General shape: 50 | * 51 | * sections: $ReadOnlyArray<{ 52 | * data: $ReadOnlyArray, 53 | * renderItem?: ({item: SectionItem, ...}) => ?React.Element<*>, 54 | * ItemSeparatorComponent?: ?ReactClass<{highlighted: boolean, ...}>, 55 | * }> 56 | ]] 57 | sections: ReadOnlyArray, 58 | } 59 | 60 | type OptionalProps = { 61 | 62 | --[[* 63 | * Default renderer for every item in every section. Can be over-ridden on a per-section basis. 64 | ]] 65 | renderItem: (( 66 | info: { 67 | item: Item, 68 | index: number, 69 | section: SectionT, 70 | separators: { 71 | highlight: () -> (), 72 | unhighlight: () -> (), 73 | updateProps: (select: "leading" | "trailing", newProps: Object) -> (), 74 | }, 75 | } 76 | ) -> React_Element?)?, 77 | --[[* 78 | * A marker property for telling the list to re-render (since it implements `PureComponent`). If 79 | * any of your `renderItem`, Header, Footer, etc. functions depend on anything outside of the 80 | * `data` prop, stick it here and treat it immutably. 81 | ]] 82 | extraData: any?, 83 | --[[* 84 | * How many items to render in the initial batch. This should be enough to fill the screen but not 85 | * much more. Note these items will never be unmounted as part of the windowed rendering in order 86 | * to improve perceived performance of scroll-to-top actions. 87 | ]] 88 | initialNumToRender: number?, 89 | --[[* 90 | * Reverses the direction of scroll. Uses scale transforms of -1. 91 | ]] 92 | inverted: boolean?, 93 | --[[* 94 | * Used to extract a unique key for a given item at the specified index. Key is used for caching 95 | * and as the react key to track item re-ordering. The default extractor checks item.key, then 96 | * falls back to using the index, like react does. Note that this sets keys for each item, but 97 | * each overall section still needs its own key. 98 | ]] 99 | keyExtractor: ((item: Item, index: number) -> string)?, 100 | --[[* 101 | * Called once when the scroll position gets within `onEndReachedThreshold` of the rendered 102 | * content. 103 | ]] 104 | onEndReached: ((info: { 105 | distanceFromEnd: number, 106 | }) -> ())?, 107 | --[[* 108 | * Note: may have bugs (missing content) in some circumstances - use at your own risk. 109 | * 110 | * This may improve scroll performance for large lists. 111 | ]] 112 | removeClippedSubviews: boolean?, 113 | } 114 | 115 | --[[ ROBLOX deviation: can't express diff 116 | {| ...$Diff, { 117 | getItem: $PropertyType, 'getItem'>, 118 | getItemCount: $PropertyType, 'getItemCount'>, 119 | renderItem: $PropertyType, 'renderItem'>, 120 | keyExtractor: $PropertyType, 'keyExtractor'>, 121 | ... 122 | }> 123 | ]] 124 | export type Props = VirtualizedSectionList & RequiredProps & OptionalProps 125 | 126 | export type SectionList = { 127 | props: Props, 128 | scrollToLocation: (self: SectionList, params: ScrollToLocationParamsType) -> (), 129 | recordInteraction: (self: SectionList) -> (), 130 | flashScrollIndicators: (self: SectionList) -> (), 131 | getScrollResponder: (self: SectionList) -> ScrollResponderType?, 132 | getScrollableNode: (self: SectionList) -> any, 133 | setNativeProps: (self: SectionList, props: Object) -> (), 134 | _wrapperListRef: React_ElementRef?, 135 | _captureRef: (ref: React_ElementRef?) -> (), 136 | } 137 | 138 | --[[* 139 | * A performant interface for rendering sectioned lists, supporting the most handy features: 140 | * 141 | * - Fully cross-platform. 142 | * - Configurable viewability callbacks. 143 | * - List header support. 144 | * - List footer support. 145 | * - Item separator support. 146 | * - Section header support. 147 | * - Section separator support. 148 | * - Heterogeneous data and item rendering support. 149 | * - Pull to Refresh. 150 | * - Scroll loading. 151 | * 152 | * If you don't need section support and want a simpler interface, use 153 | * [``](https://reactnative.dev/docs/flatlist). 154 | * 155 | * Simple Examples: 156 | * 157 | * } 159 | * renderSectionHeader={({section}) =>

} 160 | * sections={[ // homogeneous rendering between sections 161 | * {data: [...], title: ...}, 162 | * {data: [...], title: ...}, 163 | * {data: [...], title: ...}, 164 | * ]} 165 | * /> 166 | * 167 | * 174 | * 175 | * This is a convenience wrapper around [``](docs/virtualizedlist), 176 | * and thus inherits its props (as well as those of `ScrollView`) that aren't explicitly listed 177 | * here, along with the following caveats: 178 | * 179 | * - Internal state is not preserved when content scrolls out of the render window. Make sure all 180 | * your data is captured in the item data or external stores like Flux, Redux, or Relay. 181 | * - This is a `PureComponent` which means that it will not re-render if `props` remain shallow- 182 | * equal. Make sure that everything your `renderItem` function depends on is passed as a prop 183 | * (e.g. `extraData`) that is not `===` after updates, otherwise your UI may not update on 184 | * changes. This includes the `data` prop and parent component state. 185 | * - In order to constrain memory and enable smooth scrolling, content is rendered asynchronously 186 | * offscreen. This means it's possible to scroll faster than the fill rate and momentarily see 187 | * blank content. This is a tradeoff that can be adjusted to suit the needs of each application, 188 | * and we are working on improving it behind the scenes. 189 | * - By default, the list looks for a `key` prop on each item and uses that for the React key. 190 | * Alternatively, you can provide a custom `keyExtractor` prop. 191 | * 192 | ]] 193 | local SectionList = React.PureComponent:extend("SectionList") 194 | 195 | function SectionList:init(props) 196 | self.props = props 197 | self._captureRef = function(ref) 198 | self._wrapperListRef = ref 199 | end 200 | end 201 | 202 | --[[* 203 | * Scrolls to the item at the specified `sectionIndex` and `itemIndex` (within the section) 204 | * positioned in the viewable area such that `viewPosition` 0 places it at the top (and may be 205 | * covered by a sticky header), 1 at the bottom, and 0.5 centered in the middle. `viewOffset` is a 206 | * fixed number of pixels to offset the final target position, e.g. to compensate for sticky 207 | * headers. 208 | * 209 | * Note: cannot scroll to locations outside the render window without specifying the 210 | * `getItemLayout` prop. 211 | ]] 212 | function SectionList:scrollToLocation(params: ScrollToLocationParamsType): () 213 | if 214 | self._wrapperListRef ~= nil --[[ ROBLOX CHECK: loose inequality used upstream ]] 215 | then 216 | self._wrapperListRef:scrollToLocation(params) 217 | end 218 | end 219 | 220 | --[[* 221 | * Tells the list an interaction has occurred, which should trigger viewability calculations, e.g. 222 | * if `waitForInteractions` is true and the user has not scrolled. This is typically called by 223 | * taps on items or by navigation actions. 224 | ]] 225 | function SectionList:recordInteraction(): () 226 | local listRef = if self._wrapperListRef then self._wrapperListRef:getListRef() else self._wrapperListRef 227 | if listRef then 228 | listRef:recordInteraction() 229 | end 230 | end 231 | 232 | --[[* 233 | * Displays the scroll indicators momentarily. 234 | * 235 | * @platform ios 236 | ]] 237 | function SectionList:flashScrollIndicators(): () 238 | local listRef = if self._wrapperListRef then self._wrapperListRef:getListRef() else self._wrapperListRef 239 | if listRef then 240 | listRef:flashScrollIndicators() 241 | end 242 | end 243 | 244 | --[[* 245 | * Provides a handle to the underlying scroll responder. 246 | ]] 247 | function SectionList:getScrollResponder(): ScrollResponderType? 248 | local listRef = if self._wrapperListRef then self._wrapperListRef:getListRef() else self._wrapperListRef 249 | if Boolean.toJSBoolean(listRef) then 250 | return listRef:getScrollResponder() 251 | end 252 | return nil -- ROBLOX deviation: explicit return 253 | end 254 | 255 | function SectionList:getScrollableNode(): any 256 | local listRef = if self._wrapperListRefthen then self._wrapperListRef:getListRef() else self._wrapperListRef 257 | if Boolean.toJSBoolean(listRef) then 258 | return listRef:getScrollableNode() 259 | end 260 | return nil -- ROBLOX deviation: explicit return 261 | end 262 | 263 | function SectionList:setNativeProps(props: Object): () 264 | local listRef = if self._wrapperListRef then self._wrapperListRef:getListRef() else self._wrapperListRef 265 | if Boolean.toJSBoolean(listRef) then 266 | listRef:setNativeProps(props) 267 | end 268 | end 269 | 270 | function SectionList:render() 271 | local _stickySectionHeadersEnabled, restProps = 272 | self.props.stickySectionHeadersEnabled, 273 | Object.assign({}, self.props, { stickySectionHeadersEnabled = Object.None }) 274 | local stickySectionHeadersEnabled = if _stickySectionHeadersEnabled ~= nil 275 | then _stickySectionHeadersEnabled 276 | else Platform.OS == "ios" 277 | return React.createElement( 278 | VirtualizedSectionList, 279 | Object.assign({}, restProps, { 280 | stickySectionHeadersEnabled = stickySectionHeadersEnabled, 281 | ref = self._captureRef, 282 | getItemCount = function(items) 283 | return #items 284 | end, 285 | getItem = function(items, index) 286 | return items[index] 287 | end, 288 | }) 289 | ) 290 | end 291 | exports.default = SectionList 292 | return exports 293 | -------------------------------------------------------------------------------- /src/Lists/ViewabilityHelper.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Lists/ViewabilityHelper.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow 9 | * @format 10 | ]] 11 | local srcWorkspace = script.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | local Array = LuauPolyfill.Array 15 | local Map = LuauPolyfill.Map 16 | local Set = LuauPolyfill.Set 17 | local Object = LuauPolyfill.Object 18 | local console = LuauPolyfill.console 19 | local setTimeout = LuauPolyfill.setTimeout 20 | local clearTimeout = LuauPolyfill.clearTimeout 21 | local invariant = require(srcWorkspace.jsUtils.invariant) 22 | 23 | type Timeout = LuauPolyfill.Timeout 24 | type Object = LuauPolyfill.Object 25 | type Set = LuauPolyfill.Set 26 | type Map = LuauPolyfill.Map 27 | type Array = LuauPolyfill.Array 28 | export type ViewToken = Object & { 29 | item: any, 30 | key: string, 31 | index: number?, 32 | isViewable: boolean, 33 | section: any?, 34 | } 35 | 36 | export type ViewabilityConfigCallbackPair = Object & { 37 | viewabilityConfig: ViewabilityConfig, 38 | onViewableItemsChanged: ( 39 | info: Object & { 40 | viewableItems: Array, 41 | changed: Array, 42 | } 43 | ) -> (), 44 | } 45 | 46 | export type ViewabilityConfig = { 47 | --[[ 48 | Minimum amount of time (in milliseconds) that an item must be physically viewable before the 49 | viewability callback will be fired. A high number means that scrolling through content without 50 | stopping will not mark the content as viewable. 51 | ]] 52 | minimumViewTime: number?, 53 | 54 | --[[ 55 | Percent of viewport that must be covered for a partially occluded item to count as 56 | "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means 57 | that a single pixel in the viewport makes the item viewable, and a value of 100 means that 58 | an item must be either entirely visible or cover the entire viewport to count as viewable. 59 | ]] 60 | viewAreaCoveragePercentThreshold: number?, 61 | 62 | --[[ 63 | Similar to `viewAreaPercentThreshold`, but considers the percent of the item that is visible, 64 | rather than the fraction of the viewable area it covers. 65 | ]] 66 | itemVisiblePercentThreshold: number?, 67 | 68 | --[[ 69 | Nothing is considered viewable until the user scrolls or `recordInteraction` is called after 70 | render. 71 | ]] 72 | waitForInteraction: boolean?, 73 | } 74 | -- ROBLOX DEVIATION: Declare Metrics type for reuse later 75 | type Metrics = Object & { 76 | length: number, 77 | offset: number, 78 | } 79 | 80 | -- ROBLOX DEVIATION: predeclare functions 81 | local _isEntirelyVisible 82 | local _getPixelsVisible 83 | local _isViewable 84 | 85 | --[[* 86 | * A Utility class for calculating viewable items based on current metrics like scroll position and 87 | * layout. 88 | * 89 | * An item is said to be in a "viewable" state when any of the following 90 | * is true for longer than `minimumViewTime` milliseconds (after an interaction if `waitForInteraction` 91 | * is true): 92 | * 93 | * - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item 94 | * visible in the view area >= `itemVisiblePercentThreshold`. 95 | * - Entirely visible on screen 96 | ]] 97 | export type ViewabilityHelper = { 98 | _config: ViewabilityConfig, 99 | _hasInteracted: boolean, 100 | _timers: Set, 101 | _viewableIndices: Array, 102 | _viewableItems: Map, 103 | dispose: (self: ViewabilityHelper) -> (), 104 | computeViewableItems: ( 105 | self: ViewabilityHelper, 106 | itemCount: number, 107 | scrollOffset: number, 108 | viewportHeight: number, 109 | getFrameMetrics: (index: number) -> Metrics?, 110 | renderRange: (Object & { 111 | first: number, 112 | last: number, 113 | })? 114 | ) -> Array, 115 | onUpdate: ( 116 | self: ViewabilityHelper, 117 | itemCount: number, 118 | scrollOffset: number, 119 | viewportHeight: number, 120 | getFrameMetrics: (index: number) -> Metrics?, 121 | createViewToken: (index: number, isViewable: boolean) -> ViewToken, 122 | onViewableItemsChanged: (Object & { 123 | viewableItems: Array, 124 | changed: Array, 125 | }) -> (), -- Optional optimization to reduce the scan size 126 | renderRange: (Object & { 127 | first: number, 128 | last: number, 129 | })? 130 | ) -> (), 131 | resetViewableIndices: (self: ViewabilityHelper) -> (), 132 | recordInteraction: (self: ViewabilityHelper) -> (), 133 | _onUpdateSync: ( 134 | self: ViewabilityHelper, 135 | viewableIndices: Array, 136 | onViewableItemsChanged: (Object & { 137 | viewableItems: Array, 138 | changed: Array, 139 | }) -> (), 140 | createViewToken: (index: number, isViewable: boolean) -> ViewToken 141 | ) -> (), 142 | } 143 | 144 | local ViewabilityHelper = {} 145 | ViewabilityHelper.__index = ViewabilityHelper 146 | function ViewabilityHelper.new(config: ViewabilityConfig?): ViewabilityHelper 147 | local self = setmetatable({}, ViewabilityHelper) 148 | if config == nil then 149 | config = { viewAreaCoveragePercentThreshold = 0 } 150 | end 151 | self._hasInteracted = false 152 | self._timers = Set.new() :: Set 153 | self._viewableIndices = {} :: Array 154 | self._viewableItems = Map.new() :: Map 155 | self._config = config 156 | return (self :: any) :: ViewabilityHelper 157 | end 158 | 159 | function ViewabilityHelper:dispose() 160 | --[[ $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This 161 | * comment suppresses an error found when Flow v0.63 was deployed. To see 162 | * the error delete this comment and run Flow. ]] 163 | self._timers:forEach(function(value) 164 | clearTimeout(value) 165 | end) 166 | end 167 | 168 | function ViewabilityHelper:computeViewableItems( 169 | itemCount: number, 170 | scrollOffset: number, 171 | viewportHeight: number, 172 | getFrameMetrics: (index: number) -> Metrics?, 173 | renderRange: (Object & { 174 | first: number, 175 | last: number, 176 | })? 177 | ): Array 178 | local itemVisiblePercentThreshold, viewAreaCoveragePercentThreshold = 179 | self._config.itemVisiblePercentThreshold, self._config.viewAreaCoveragePercentThreshold 180 | local viewAreaMode = viewAreaCoveragePercentThreshold ~= nil 181 | local viewablePercentThreshold = if viewAreaMode 182 | then viewAreaCoveragePercentThreshold 183 | else itemVisiblePercentThreshold 184 | invariant( 185 | viewablePercentThreshold ~= nil 186 | and (itemVisiblePercentThreshold ~= nil) ~= (viewAreaCoveragePercentThreshold ~= nil), 187 | "Must set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold" 188 | ) 189 | local viewableIndices = {} 190 | if itemCount == 0 then 191 | return viewableIndices 192 | end 193 | local firstVisible = 0 194 | local first, last 195 | do 196 | local ref = if renderRange then renderRange else { first = 1, last = itemCount } 197 | first, last = ref.first :: number, ref.last :: number 198 | end 199 | if last > itemCount then 200 | console.warn( 201 | "Invalid render range computing viewability { renderRange = " 202 | .. tostring(renderRange) 203 | .. ", itemCount = " 204 | .. tostring(itemCount) 205 | .. " }" 206 | ) 207 | return {} 208 | end 209 | local idx_ = first 210 | while idx_ <= last do 211 | local idx = idx_ 212 | local metrics = getFrameMetrics(idx) 213 | if not metrics then 214 | idx_ += 1 215 | continue 216 | end 217 | local top = (metrics :: Metrics).offset - scrollOffset 218 | local bottom = top + (metrics :: Metrics).length 219 | if top < viewportHeight and bottom > 0 then 220 | firstVisible = idx 221 | if 222 | _isViewable( 223 | viewAreaMode, 224 | viewablePercentThreshold, 225 | top, 226 | bottom, 227 | viewportHeight, 228 | (metrics :: Metrics).length 229 | ) 230 | then 231 | table.insert(viewableIndices, idx) 232 | end 233 | elseif firstVisible >= 1 then 234 | break 235 | end 236 | idx_ += 1 237 | end 238 | return viewableIndices 239 | end 240 | 241 | --[[* 242 | * Figures out which items are viewable and how that has changed from before and calls 243 | * `onViewableItemsChanged` as appropriate. 244 | ]] 245 | function ViewabilityHelper:onUpdate( 246 | itemCount: number, 247 | scrollOffset: number, 248 | viewportHeight: number, 249 | getFrameMetrics: (index: number) -> Metrics?, 250 | createViewToken: (index: number, isViewable: boolean) -> ViewToken, 251 | onViewableItemsChanged: ( 252 | Object & { 253 | viewableItems: Array, 254 | changed: Array, 255 | } 256 | ) -> (), -- Optional optimization to reduce the scan size 257 | renderRange: (Object & { 258 | first: number, 259 | last: number, 260 | })? 261 | ) 262 | if (self._config.waitForInteraction and not self._hasInteracted) or itemCount == 0 or not getFrameMetrics(1) then 263 | return 264 | end 265 | 266 | local viewableIndices = {} :: Array 267 | if itemCount then 268 | viewableIndices = 269 | self:computeViewableItems(itemCount, scrollOffset, viewportHeight, getFrameMetrics, renderRange) 270 | end 271 | if 272 | #self._viewableIndices == #viewableIndices 273 | and Array.every(self._viewableIndices, function(v, ii) 274 | return v == viewableIndices[ii] 275 | end) 276 | then 277 | -- We might get a lot of scroll events where visibility doesn't change and we don't want to do 278 | -- extra work in those cases. 279 | return 280 | end 281 | self._viewableIndices = viewableIndices 282 | if self._config.minimumViewTime then 283 | local handle 284 | handle = setTimeout(function() 285 | --[[ $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This 286 | * comment suppresses an error found when Flow v0.63 was deployed. To 287 | * see the error delete this comment and run Flow. ]] 288 | self._timers:delete(handle) 289 | self:_onUpdateSync(viewableIndices, onViewableItemsChanged, createViewToken) 290 | end, self._config.minimumViewTime) 291 | --[[ $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This 292 | * comment suppresses an error found when Flow v0.63 was deployed. To see 293 | * the error delete this comment and run Flow. ]] 294 | self._timers:add(handle) 295 | else 296 | self:_onUpdateSync(viewableIndices, onViewableItemsChanged, createViewToken) 297 | end 298 | end 299 | 300 | function ViewabilityHelper:resetViewableIndices() 301 | self._viewableIndices = {} 302 | end 303 | 304 | function ViewabilityHelper:recordInteraction() 305 | self._hasInteracted = true 306 | end 307 | 308 | function ViewabilityHelper:_onUpdateSync(viewableIndicesToCheck, onViewableItemsChanged, createViewToken) 309 | -- Filter out indices that have gone out of view since this call was scheduled. 310 | viewableIndicesToCheck = Array.filter(viewableIndicesToCheck, function(ii) 311 | return Array.includes(self._viewableIndices, ii) 312 | end) 313 | local prevItems = self._viewableItems 314 | local nextItems = Map.new(Array.map(viewableIndicesToCheck, function(ii) 315 | local viewable = createViewToken(ii, true) 316 | return { viewable.key, viewable } 317 | end)) 318 | local changed = {} 319 | for _, key in ipairs(nextItems:keys()) do 320 | if not prevItems:has(key) then 321 | table.insert(changed, nextItems:get(key)) 322 | end 323 | end 324 | for _, key in ipairs(prevItems:keys()) do 325 | if not nextItems:has(key) then 326 | local viewable = prevItems:get(key) 327 | table.insert(changed, Object.assign({}, viewable, { isViewable = false })) 328 | end 329 | end 330 | if #changed > 0 then 331 | self._viewableItems = nextItems 332 | onViewableItemsChanged({ 333 | viewableItems = Array.from(nextItems:values()), 334 | changed = changed, 335 | viewabilityConfig = self._config, 336 | }) 337 | end 338 | end 339 | 340 | function _isViewable( 341 | viewAreaMode: boolean, 342 | viewablePercentThreshold: number, 343 | top: number, 344 | bottom: number, 345 | viewportHeight: number, 346 | itemLength: number 347 | ): boolean 348 | if _isEntirelyVisible(top, bottom, viewportHeight) then 349 | return true 350 | else 351 | local pixels = _getPixelsVisible(top, bottom, viewportHeight) 352 | local percent = 100 * if viewAreaMode then pixels / viewportHeight else pixels / itemLength 353 | return percent >= viewablePercentThreshold 354 | end 355 | end 356 | 357 | function _getPixelsVisible(top: number, bottom: number, viewportHeight: number): number 358 | local visibleHeight = math.min(bottom, viewportHeight) - math.max(top, 0) 359 | return math.max(0, visibleHeight) 360 | end 361 | 362 | function _isEntirelyVisible(top: number, bottom: number, viewportHeight: number): boolean 363 | return top >= 0 and bottom <= viewportHeight and bottom > top 364 | end 365 | 366 | return ViewabilityHelper 367 | -------------------------------------------------------------------------------- /src/Lists/VirtualizeUtils.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Lists/VirtualizeUtils.js 2 | --[[ 3 | Copyright (c) Meta Platforms, Inc. and affiliates. 4 | 5 | This source code is licensed under the MIT license found in the 6 | LICENSE file in the root directory of this source tree. 7 | ]] 8 | 9 | local srcWorkspace = script.Parent.Parent 10 | local Packages = srcWorkspace.Parent 11 | local LuauPolyfill = require(Packages.LuauPolyfill) 12 | local Error = LuauPolyfill.Error 13 | type Object = LuauPolyfill.Object 14 | 15 | local HttpService = game:GetService("HttpService") 16 | 17 | type Array = LuauPolyfill.Array 18 | local exports = {} 19 | local invariant = require(srcWorkspace.jsUtils.invariant) 20 | 21 | --[[* 22 | * Used to find the indices of the frames that overlap the given offsets. Useful for finding the 23 | * items that bound different windows of content, such as the visible area or the buffered overscan 24 | * area. 25 | ]] 26 | local function elementsThatOverlapOffsets( 27 | offsets: Array, 28 | itemCount: number, 29 | getFrameMetrics: (index: number) -> Object & { length: number, offset: number } 30 | ): Array 31 | local out = {} 32 | local outLength = 0 33 | for ii = 1, itemCount do 34 | local frame = getFrameMetrics(ii) 35 | local trailingOffset = frame.offset + frame.length 36 | for kk = 1, #offsets do 37 | if out[kk] == nil and trailingOffset >= offsets[kk] then 38 | out[kk] = ii 39 | outLength += 1 40 | if kk == #offsets then 41 | -- ROBLOX deviation START: Avoid excess HttpService:JSONEncode calls 42 | if outLength ~= #offsets then 43 | invariant( 44 | outLength == #offsets, 45 | "bad offsets input, should be in increasing order: %s", 46 | HttpService:JSONEncode(offsets) 47 | ) 48 | end 49 | -- ROBLOX deviation END 50 | return out 51 | end 52 | end 53 | end 54 | end 55 | return out 56 | end 57 | exports.elementsThatOverlapOffsets = elementsThatOverlapOffsets 58 | 59 | --[[* 60 | * Computes the number of elements in the `next` range that are new compared to the `prev` range. 61 | * Handy for calculating how many new items will be rendered when the render window changes so we 62 | * can restrict the number of new items render at once so that content can appear on the screen 63 | * faster. 64 | ]] 65 | local function newRangeCount( 66 | prev: Object & { first: number, last: number }, 67 | next: Object & { first: number, last: number } 68 | ): number 69 | return next.last 70 | - next.first 71 | + 1 72 | - math.max(0, 1 + math.min(next.last, prev.last) - math.max(next.first, prev.first)) 73 | end 74 | exports.newRangeCount = newRangeCount 75 | 76 | --[[* 77 | * Custom logic for determining which items should be rendered given the current frame and scroll 78 | * metrics, as well as the previous render state. The algorithm may evolve over time, but generally 79 | * prioritizes the visible area first, then expands that with overscan regions ahead and behind, 80 | * biased in the direction of scroll. 81 | ]] 82 | local function computeWindowedRenderLimits( 83 | data: any, 84 | getItemCount: (data: any) -> number, 85 | maxToRenderPerBatch: number, 86 | windowSize: number, 87 | prev: { first: number, last: number }, -- ROBLOX deviation: narrow type 88 | getFrameMetricsApprox: (index: number) -> { length: number, offset: number }, -- ROBLOX deviation: narrow return type 89 | scrollMetrics: Object & { 90 | dt: number, 91 | offset: number, 92 | velocity: number, 93 | visibleLength: number, 94 | } 95 | ): { first: number, last: number } --ROBLOX deviation: narrow type 96 | local itemCount = getItemCount(data) 97 | if itemCount == 0 then 98 | return prev 99 | end 100 | local offset, velocity, visibleLength = scrollMetrics.offset, scrollMetrics.velocity, scrollMetrics.visibleLength 101 | 102 | -- Start with visible area, then compute maximum overscan region by expanding from there, biased 103 | -- in the direction of scroll. Total overscan area is capped, which should cap memory consumption 104 | -- too. 105 | local visibleBegin = math.max(0, offset) 106 | local visibleEnd = visibleBegin + visibleLength 107 | local overscanLength = (windowSize - 1) * visibleLength 108 | 109 | -- Considering velocity seems to introduce more churn than it's worth. 110 | local leadFactor = 0.5 -- Math.max(0, Math.min(1, velocity / 25 + 0.5)); 111 | 112 | -- ROBLOX FIXME Luau: needs normalization - velocity is known to be of type `number` 113 | local fillPreference = if velocity :: number > 1 114 | then "after" 115 | else if (velocity :: number) < -1 then "before" else "none" 116 | local overscanBegin = math.max(0, visibleBegin - (1 - leadFactor) * overscanLength) 117 | local overscanEnd = math.max(0, visibleEnd + leadFactor * overscanLength) 118 | local lastItemOffset = getFrameMetricsApprox(itemCount).offset -- ROBLOX deviation: index start at 1 119 | 120 | -- ROBLOX FIXME Luau: needs normalization - lastItemOffset is known to be of type `number` 121 | if lastItemOffset < overscanBegin then 122 | -- Entire list is before our overscan window 123 | return { first = math.max(1, itemCount - maxToRenderPerBatch), last = itemCount } -- ROBLOX deviation: index starts at 1 124 | end 125 | 126 | -- Find the indices that correspond to the items at the render boundaries we're targeting. 127 | local overscanFirst, first, last, overscanLast = table.unpack( 128 | elementsThatOverlapOffsets( 129 | { overscanBegin, visibleBegin, visibleEnd, overscanEnd }, 130 | itemCount, 131 | getFrameMetricsApprox 132 | ), 133 | 1, 134 | 4 135 | ) 136 | 137 | overscanFirst = if overscanFirst == nil then 1 else overscanFirst -- ROBLOX deviation: index starts at 1 138 | first = if first == nil then math.max(1, overscanFirst) else first -- ROBLOX deviation: index starts at 1 139 | 140 | overscanLast = if overscanLast == nil then itemCount else overscanLast -- ROBLOX deviation: index start at 1 141 | last = if last == nil then math.min(overscanLast, first + maxToRenderPerBatch - 1) else last 142 | 143 | local visible = { first = first, last = last } 144 | 145 | -- We want to limit the number of new cells we're rendering per batch so that we can fill thezat once, the user 146 | -- could be staring at white space for a long time waiting for a bunch of offscreen content to 147 | -- render. 148 | local newCellCount = newRangeCount(prev, visible) 149 | while true do 150 | if first <= overscanFirst and last >= overscanLast then 151 | -- If we fill the entire overscan range, we're done. 152 | break 153 | end 154 | 155 | local maxNewCells = newCellCount >= maxToRenderPerBatch 156 | local firstWillAddMore = first <= prev.first or first > prev.last 157 | local firstShouldIncrement = first > overscanFirst and (not maxNewCells or not firstWillAddMore) 158 | local lastWillAddMore = last >= prev.last or last < prev.first 159 | local lastShouldIncrement = last < overscanLast and (not maxNewCells or not lastWillAddMore) 160 | if maxNewCells and not firstShouldIncrement and not lastShouldIncrement then 161 | -- We only want to stop if we've hit maxNewCells AND we cannot increment first or last 162 | -- without rendering new items. This let's us preserve as many already rendered items as 163 | -- possible, reducing render churn and keeping the rendered overscan range as large as 164 | -- possible. 165 | break 166 | end 167 | if firstShouldIncrement and not (fillPreference == "after" and lastShouldIncrement and lastWillAddMore) then 168 | if firstWillAddMore then 169 | newCellCount += 1 170 | end 171 | first -= 1 172 | end 173 | if lastShouldIncrement and not (fillPreference == "before" and firstShouldIncrement and firstWillAddMore) then 174 | if lastWillAddMore then 175 | newCellCount += 1 176 | end 177 | last += 1 178 | end 179 | end 180 | if 181 | not ( 182 | last >= first 183 | and first >= 1 184 | and last <= itemCount -- ROBLOX deviation: index start at 1 185 | and first >= overscanFirst 186 | and last <= overscanLast 187 | and first <= visible.first 188 | and last >= visible.last 189 | ) 190 | then 191 | error(Error.new("Bad window calculation " .. HttpService:JSONEncode({ 192 | first = first, 193 | last = last, 194 | itemCount = itemCount, 195 | overscanFirst = overscanFirst, 196 | overscanLast = overscanLast, 197 | visible = visible, 198 | }))) 199 | end 200 | return { first = first, last = last } 201 | end 202 | exports.computeWindowedRenderLimits = computeWindowedRenderLimits 203 | 204 | local function keyExtractor(item: any, index: number): string 205 | if typeof(item) == "table" and item.key ~= nil then 206 | return item.key 207 | end 208 | if typeof(item) == "table" and item.id ~= nil then 209 | return item.id 210 | end 211 | return tostring(index) 212 | end 213 | exports.keyExtractor = keyExtractor 214 | return exports 215 | -------------------------------------------------------------------------------- /src/Lists/VirtualizedListContext.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Lists/VirtualizedListContext.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow strict-local 9 | * @format 10 | ]] 11 | local Packages = script.Parent.Parent.Parent 12 | local LuauPolyfill = require(Packages.LuauPolyfill) 13 | local Object = LuauPolyfill.Object 14 | type Object = LuauPolyfill.Object 15 | 16 | -- ROBLOX deviation: unavailable Types 17 | type ReadOnly = T 18 | 19 | -- ROBLOX FIXME: use proper type when available. Circular dep maybe? 20 | -- local VirtualizedListModule = require(script.Parent.VirtualizedList) 21 | -- type VirtualizedList = VirtualizedListModule.VirtualizedList 22 | type VirtualizedList = Object 23 | 24 | local React = require(Packages.React) 25 | local useMemo = React.useMemo 26 | local useContext = React.useContext 27 | type React_Node = React.Node 28 | type React_Context = React.Context 29 | 30 | local exports = {} 31 | 32 | type Frame = ReadOnly<{ 33 | offset: number, 34 | length: number, 35 | index: number, 36 | inLayout: boolean, 37 | }> 38 | 39 | export type ChildListState = ReadOnly<{ 40 | first: number, 41 | last: number, 42 | frames: { [number]: Frame }, 43 | }> 44 | 45 | -- Data propagated through nested lists (regardless of orientation) that is 46 | -- useful for producing diagnostics for usage errors involving nesting (e.g 47 | -- missing/duplicate keys). 48 | export type ListDebugInfo = ReadOnly<{ 49 | cellKey: string, 50 | listKey: string, 51 | parent: ListDebugInfo?, 52 | -- We include all ancestors regardless of orientation, so this is not always 53 | -- identical to the child's orientation. 54 | horizontal: boolean, 55 | }> 56 | 57 | type Context = ReadOnly<{ 58 | cellKey: string?, 59 | getScrollMetrics: () -> { 60 | contentLength: number, 61 | dOffset: number, 62 | dt: number, 63 | offset: number, 64 | timestamp: number, 65 | velocity: number, 66 | visibleLength: number, 67 | }, 68 | horizontal: boolean?, 69 | getOutermostParentListRef: () -> VirtualizedList, 70 | getNestedChildState: (string) -> ChildListState?, 71 | registerAsNestedChild: ({ 72 | cellKey: string, 73 | key: string, 74 | ref: VirtualizedList, 75 | parentDebugInfo: ListDebugInfo, 76 | }) -> ChildListState?, 77 | unregisterAsNestedChild: ({ 78 | key: string, 79 | state: ChildListState, 80 | }) -> (), 81 | debugInfo: ListDebugInfo, 82 | }> 83 | 84 | local VirtualizedListContext: React_Context = React.createContext(nil) 85 | exports.VirtualizedListContext = VirtualizedListContext 86 | 87 | if _G.__DEV__ then 88 | VirtualizedListContext.displayName = "VirtualizedListContext" 89 | end 90 | 91 | --[[* 92 | * Resets the context. Intended for use by portal-like components (e.g. Modal). 93 | ]] 94 | local function VirtualizedListContextResetter(ref: { 95 | children: React_Node, 96 | }): React_Node 97 | local children = ref.children 98 | return React.createElement(VirtualizedListContext.Provider, { 99 | value = nil, 100 | }, children) 101 | end 102 | exports.VirtualizedListContextResetter = VirtualizedListContextResetter 103 | 104 | --[[* 105 | * Sets the context with memoization. Intended to be used by `VirtualizedList`. 106 | ]] 107 | local function VirtualizedListContextProvider(ref: { 108 | children: React_Node, 109 | value: Context, 110 | }): React_Node 111 | local children, value = ref.children, ref.value 112 | -- Avoid setting a newly created context object if the values are identical. 113 | local context = useMemo( 114 | function() 115 | return { 116 | cellKey = nil, 117 | getScrollMetrics = value.getScrollMetrics, 118 | horizontal = value.horizontal, 119 | getOutermostParentListRef = value.getOutermostParentListRef, 120 | getNestedChildState = value.getNestedChildState, 121 | registerAsNestedChild = value.registerAsNestedChild, 122 | unregisterAsNestedChild = value.unregisterAsNestedChild, 123 | debugInfo = { 124 | cellKey = value.debugInfo.cellKey, 125 | horizontal = value.debugInfo.horizontal, 126 | listKey = value.debugInfo.listKey, 127 | parent = value.debugInfo.parent, 128 | }, 129 | } 130 | end, 131 | -- ROBLOX FIXME Luau: can't handle array with mixed types 132 | { 133 | value.getScrollMetrics, 134 | value.horizontal :: any, 135 | value.getOutermostParentListRef, 136 | value.getNestedChildState :: any, 137 | value.registerAsNestedChild :: any, 138 | value.unregisterAsNestedChild :: any, 139 | value.debugInfo.cellKey :: any, 140 | value.debugInfo.horizontal :: any, 141 | value.debugInfo.listKey :: any, 142 | value.debugInfo.parent :: any, 143 | } 144 | ) 145 | return React.createElement(VirtualizedListContext.Provider, { 146 | value = context, 147 | }, children) 148 | end 149 | exports.VirtualizedListContextProvider = VirtualizedListContextProvider 150 | 151 | --[[* 152 | * Sets the `cellKey`. Intended to be used by `VirtualizedList` for each cell. 153 | ]] 154 | local function VirtualizedListCellContextProvider(ref: { 155 | cellKey: string, 156 | children: React_Node, 157 | }): React_Node 158 | local cellKey, children = ref.cellKey, ref.children 159 | local context = useContext(VirtualizedListContext) 160 | return React.createElement(VirtualizedListContext.Provider, { 161 | value = if context == nil then nil else Object.assign(table.clone(context), { cellKey = cellKey }), 162 | }, children) 163 | end 164 | exports.VirtualizedListCellContextProvider = VirtualizedListCellContextProvider 165 | 166 | return exports 167 | -------------------------------------------------------------------------------- /src/Lists/VirtualizedSectionList.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Lists/VirtualizedSectionList.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @flow 9 | * @format 10 | ]] 11 | local srcWorkspace = script.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | local Array = LuauPolyfill.Array 15 | local Boolean = LuauPolyfill.Boolean 16 | local Object = LuauPolyfill.Object 17 | type Array = LuauPolyfill.Array 18 | type Object = LuauPolyfill.Object 19 | 20 | -- ROBLOX deviation: unavailable Types 21 | type Function = (...any) -> ...any 22 | type ReadOnly = T 23 | type ReadOnlyArray = Array 24 | type Shape = any 25 | type Diff = Object 26 | 27 | local invariant = require(srcWorkspace.jsUtils.invariant) 28 | local ViewabilityHelperModule = require(script.Parent.ViewabilityHelper) 29 | type ViewToken = ViewabilityHelperModule.ViewToken 30 | local defaultKeyExtractor = require(script.Parent.VirtualizeUtils).keyExtractor 31 | 32 | local View = require(srcWorkspace.Components.View.View) 33 | local VirtualizedList = require(script.Parent.VirtualizedList) 34 | 35 | local React = require(Packages.React) 36 | type React_AbstractComponent = React.AbstractComponent 37 | type React_ComponentType

= React.ComponentType

38 | type React_Element = React.ReactElement 39 | type React_ElementConfig = React.ElementConfig 40 | type React_ElementRef = React.ElementRef 41 | type React_Node = React.Node 42 | 43 | -- ROBLOX deviation: predefine variables/functions 44 | local ItemWithSeparator 45 | 46 | type Item = any 47 | 48 | export type SectionBase = { 49 | --[[* 50 | * The data for rendering items in this section. 51 | ]] 52 | data: ReadOnlyArray, 53 | --[[* 54 | * Optional key to keep track of section re-ordering. If you don't plan on re-ordering sections, 55 | * the array index will be used by default. 56 | ]] 57 | key: string?, 58 | -- Optional props will override list-wide props just for this section. 59 | renderItem: (( 60 | info: { 61 | item: SectionItemT, 62 | index: number, 63 | section: SectionBase, 64 | separators: { 65 | highlight: () -> (), 66 | unhighlight: () -> (), 67 | updateProps: (select: "leading" | "trailing", newProps: Object) -> (), 68 | }, 69 | }? 70 | ) -> (nil | React_Element))?, 71 | ItemSeparatorComponent: (React_ComponentType | nil)?, 72 | keyExtractor: (item: SectionItemT, index: (number | nil)?) -> string, 73 | } 74 | 75 | -- ROBLOX FIXME Luau: Recursive type being used with different parameters 76 | -- type RequiredProps> 77 | type RequiredProps = { 78 | sections: ReadOnlyArray, 79 | } 80 | 81 | -- ROBLOX FIXME Luau: Recursive type being used with different parameters 82 | -- type OptionalProps> 83 | type OptionalProps = { 84 | --[[* 85 | * Default renderer for every item in every section. 86 | ]] 87 | renderItem: (( 88 | info: { 89 | item: Item, 90 | index: number, 91 | section: SectionT, 92 | separators: { 93 | highlight: () -> (), 94 | unhighlight: () -> (), 95 | updateProps: (select: "leading" | "trailing", newProps: Object) -> (), 96 | }, 97 | } 98 | ) -> (nil | React_Element))?, 99 | --[[* 100 | * Rendered at the top of each section. These stick to the top of the `ScrollView` by default on 101 | * iOS. See `stickySectionHeadersEnabled`. 102 | ]] 103 | renderSectionHeader: ((info: { 104 | section: SectionT, 105 | }) -> (nil | React_Element))?, 106 | --[[* 107 | * Rendered at the bottom of each section. 108 | ]] 109 | renderSectionFooter: ((info: { 110 | section: SectionT, 111 | }) -> (nil | React_Element))?, 112 | --[[* 113 | * Rendered at the top and bottom of each section (note this is different from 114 | * `ItemSeparatorComponent` which is only rendered between items). These are intended to separate 115 | * sections from the headers above and below and typically have the same highlight response as 116 | * `ItemSeparatorComponent`. Also receives `highlighted`, `[leading/trailing][Item/Separator]`, 117 | * and any custom props from `separators.updateProps`. 118 | ]] 119 | SectionSeparatorComponent: (React.ComponentType | nil)?, 120 | 121 | --[[* 122 | * Makes section headers stick to the top of the screen until the next one pushes it off. Only 123 | * enabled by default on iOS because that is the platform standard there. 124 | ]] 125 | stickySectionHeadersEnabled: boolean?, 126 | onEndReached: (({ 127 | distanceFromEnd: number, 128 | }) -> ())?, 129 | } 130 | 131 | type VirtualizedListProps = React_ElementConfig 132 | 133 | export type Props = RequiredProps & OptionalProps & Diff]] 135 | data: any, --[[PropertyType]] 136 | }> 137 | 138 | export type ScrollToLocationParamsType = { 139 | animated: (boolean | nil)?, 140 | itemIndex: number, 141 | sectionIndex: number, 142 | viewOffset: number?, 143 | viewPosition: number?, 144 | } 145 | 146 | type State = { 147 | childProps: VirtualizedListProps, 148 | } 149 | 150 | -- ROBLOX FIXME Luau: recursive type error when used with 151 | export type VirtualizedSectionList = { 152 | scrollToLocation: (self: VirtualizedSectionList, params: ScrollToLocationParamsType) -> (), 153 | getListRef: (self: VirtualizedSectionList) -> React_ElementRef?, 154 | _getItem: ( 155 | self: VirtualizedSectionList, 156 | props: Props, 157 | sections_: ReadOnlyArray?, 158 | index: number 159 | ) -> Item?, 160 | _keyExtractor: (item: Item, index: number) -> string, 161 | _subExtractor: ( 162 | self: VirtualizedSectionList, 163 | index: number 164 | ) -> { 165 | section: SectionT, 166 | -- Key of the section or combined key for section + item 167 | key: string, 168 | -- Relative index within the section 169 | index: number?, 170 | -- True if this is the section header 171 | header: boolean?, 172 | leadingItem: Item?, 173 | leadingSection: SectionT?, 174 | trailingItem: Item?, 175 | trailingSection: SectionT?, 176 | }?, 177 | _convertViewable: (viewable: ViewToken) -> ViewToken?, 178 | _onViewableItemsChanged: (ref: { 179 | viewableItems: Array, 180 | changed: Array, 181 | }) -> (), 182 | _renderItem: (listItemCount: number) -> any?, 183 | _updatePropsFor: (cellKey: string, value: any) -> (), 184 | _updateHighlightFor: (cellKey: string, value: boolean) -> (), 185 | _setUpdateHighlightFor: (cellKey: string, updateHighlightFn: ((boolean) -> ())?) -> (), 186 | _setUpdatePropsFor: (cellKey: string, updatePropsFn: ((boolean) -> ())?) -> (), 187 | _getSeparatorComponent: ( 188 | self: VirtualizedSectionList, 189 | index: number, 190 | info_: Object?, 191 | listItemCount: number 192 | ) -> React_ComponentType?, 193 | _updateHighlightMap: Object, 194 | _updatePropsMap: Object, 195 | _listRef: React_ElementRef?, 196 | _captureRef: (ref: React_ElementRef?) -> (), 197 | } 198 | 199 | --[[* 200 | * Right now this just flattens everything into one list and uses VirtualizedList under the 201 | * hood. The only operation that might not scale well is concatting the data arrays of all the 202 | * sections when new props are received, which should be plenty fast for up to ~10,000 items. 203 | ]] 204 | local VirtualizedSectionList = React.PureComponent:extend("VirtualizedSectionList") 205 | 206 | function VirtualizedSectionList:init() 207 | self._updateHighlightMap = {} 208 | self._updatePropsMap = {} 209 | self._listRef = nil :: React_ElementRef | nil 210 | self._captureRef = function(ref) 211 | self._listRef = ref 212 | end 213 | 214 | self._keyExtractor = function(item: Item, index: number): string 215 | local info = self:_subExtractor(index) 216 | return if info then info.key else tostring(index) 217 | end 218 | 219 | self._convertViewable = function(viewable: ViewToken): ViewToken? 220 | invariant(viewable.index ~= nil, "Received a broken ViewToken") 221 | local info = self:_subExtractor(viewable.index) 222 | if not info then 223 | return nil 224 | end 225 | local keyExtractorWithNullableIndex = info.section.keyExtractor 226 | local keyExtractorWithNonNullableIndex = if self.props.keyExtractor 227 | then self.props.keyExtractor 228 | else defaultKeyExtractor 229 | local key = if keyExtractorWithNullableIndex ~= nil 230 | then keyExtractorWithNullableIndex(viewable.item, info.index) 231 | else keyExtractorWithNonNullableIndex( 232 | viewable.item, 233 | if info.index == nil 234 | then 1 --[[ROBLOX deviation: added 1 to index]] 235 | else info.index 236 | ) 237 | 238 | return Object.assign({}, viewable, { index = info.index, key = key, section = info.section }) 239 | end 240 | 241 | self._onViewableItemsChanged = function(ref: { 242 | viewableItems: Array, 243 | changed: Array, 244 | }): () 245 | local viewableItems, changed = ref.viewableItems, ref.changed 246 | local onViewableItemsChanged = self.props.onViewableItemsChanged 247 | if onViewableItemsChanged ~= nil then 248 | onViewableItemsChanged({ 249 | viewableItems = Array.filter( 250 | Array.map(viewableItems, self._convertViewable, self), 251 | Boolean.toJSBoolean 252 | ), 253 | changed = Array.filter(Array.map(changed, self._convertViewable, self), Boolean.toJSBoolean), 254 | }) 255 | end 256 | end 257 | 258 | self._renderItem = function(listItemCount: number) 259 | return function(ref: { item: Item, index: number }): React_Element? 260 | local item, index = ref.item, ref.index 261 | local info = self:_subExtractor(index) 262 | if not info then 263 | return nil 264 | end 265 | local infoIndex = info.index 266 | if infoIndex == nil then 267 | local section = info.section 268 | if info.header == true then 269 | local renderSectionHeader = self.props.renderSectionHeader 270 | return if Boolean.toJSBoolean(renderSectionHeader) 271 | then renderSectionHeader({ section = section }) 272 | else nil 273 | else 274 | local renderSectionFooter = self.props.renderSectionFooter 275 | return if Boolean.toJSBoolean(renderSectionFooter) 276 | then renderSectionFooter({ section = section }) 277 | else nil 278 | end 279 | else 280 | local renderItem = info.section.renderItem or self.props.renderItem 281 | local SeparatorComponent = self:_getSeparatorComponent(index, info, listItemCount) 282 | invariant(renderItem, "no renderItem!") 283 | return React.createElement(ItemWithSeparator, { 284 | SeparatorComponent = SeparatorComponent, 285 | LeadingSeparatorComponent = if infoIndex == 1 --[[ ROBLOX deviation: added 1 to index ]] 286 | then self.props.SectionSeparatorComponent 287 | else nil, 288 | cellKey = info.key, 289 | index = infoIndex, 290 | item = item, 291 | leadingItem = info.leadingItem, 292 | leadingSection = info.leadingSection, 293 | prevCellKey = (self:_subExtractor(index - 1) or {}).key, 294 | -- Callback to provide updateHighlight for this item 295 | setSelfHighlightCallback = self._setUpdateHighlightFor, 296 | setSelfUpdatePropsCallback = self._setUpdatePropsFor, 297 | -- Provide child ability to set highlight/updateProps for previous item using prevCellKey 298 | updateHighlightFor = self._updateHighlightFor, 299 | updatePropsFor = self._updatePropsFor, 300 | renderItem = renderItem, 301 | section = info.section, 302 | trailingItem = info.trailingItem, 303 | trailingSection = info.trailingSection, 304 | inverted = Boolean.toJSBoolean(self.props.inverted), 305 | horizontal = self.props.horizontal, 306 | }) 307 | end 308 | end 309 | end 310 | 311 | self._updatePropsFor = function(cellKey: string, value: any): () 312 | local updateProps = self._updatePropsMap[cellKey] 313 | if updateProps ~= nil then 314 | updateProps(value) 315 | end 316 | end 317 | 318 | self._updateHighlightFor = function(cellKey: string, value: boolean): () 319 | local updateHighlight = self._updateHighlightMap[cellKey] 320 | if updateHighlight ~= nil then 321 | updateHighlight(value) 322 | end 323 | end 324 | 325 | self._setUpdateHighlightFor = function(cellKey: string, updateHighlightFn: ((boolean) -> ())?): () 326 | if updateHighlightFn ~= nil then 327 | self._updateHighlightMap[cellKey] = updateHighlightFn 328 | else 329 | self._updateHighlightMap[cellKey] = nil -- ROBLOX TODO: PR upstream with fix 330 | end 331 | end 332 | 333 | self._setUpdatePropsFor = function(cellKey: string, updatePropsFn: ((boolean) -> ())?): () 334 | if updatePropsFn ~= nil then 335 | self._updatePropsMap[cellKey] = updatePropsFn 336 | else 337 | self._updatePropsMap[cellKey] = nil 338 | end 339 | end 340 | end 341 | 342 | function VirtualizedSectionList:scrollToLocation(params: ScrollToLocationParamsType) 343 | local index = params.itemIndex 344 | local i = 1 -- ROBLOX deviation: added 1 to index iteration 345 | while i < params.sectionIndex do 346 | index += self.props.getItemCount(self.props.sections[i].data) + 2 347 | i += 1 348 | end 349 | local viewOffset = params.viewOffset or 0 350 | if self._listRef == nil then 351 | return 352 | end 353 | if 354 | params.itemIndex > 1 -- ROBLOX deviation: added 1 to index 355 | and self.props.stickySectionHeadersEnabled 356 | then 357 | local frame = self._listRef.__getFrameMetricsApprox(index - params.itemIndex) 358 | viewOffset += frame.length 359 | end 360 | local toIndexParams = Object.assign({}, params, { viewOffset = viewOffset, index = index }) 361 | self._listRef:scrollToIndex(toIndexParams) 362 | end 363 | 364 | function VirtualizedSectionList:getListRef(): React_ElementRef? 365 | return self._listRef 366 | end 367 | 368 | function VirtualizedSectionList:render(): React_Node 369 | -- ROBLOX deviation: removed some properties from destructutring because they were not used 370 | local _renderItem, _sections, passThroughProps = 371 | self.props.renderItem, self.props.sections, Object.assign({}, self.props, { 372 | ItemSeparatorComponent = Object.None, 373 | SectionSeparatorComponent = Object.None, 374 | renderItem = Object.None, 375 | renderSectionFooter = Object.None, 376 | renderSectionHeader = Object.None, 377 | sections = Object.None, 378 | stickySectionHeadersEnabled = Object.None, 379 | }) 380 | 381 | local listHeaderOffset = if self.props.ListHeaderComponent then 1 else 0 382 | 383 | local stickyHeaderIndices = if self.props.stickySectionHeadersEnabled then {} else nil 384 | 385 | local itemCount = 0 386 | for _, section in ipairs(self.props.sections) do 387 | -- Track the section header indices 388 | if stickyHeaderIndices ~= nil then 389 | table.insert(stickyHeaderIndices, itemCount + listHeaderOffset) 390 | end 391 | 392 | -- Add two for the section header and footer. 393 | itemCount += 2 394 | itemCount += self.props.getItemCount(section.data) 395 | end 396 | local renderItem = self._renderItem(itemCount) 397 | 398 | return React.createElement( 399 | VirtualizedList, 400 | Object.assign({}, passThroughProps, { 401 | keyExtractor = self._keyExtractor, 402 | stickyHeaderIndices = stickyHeaderIndices, 403 | renderItem = renderItem, 404 | data = self.props.sections, 405 | getItem = function(sections, index) 406 | return self:_getItem(self.props, sections, index) 407 | end, 408 | getItemCount = function() 409 | return itemCount 410 | end, 411 | onViewableItemsChanged = if self.props.onViewableItemsChanged then self._onViewableItemsChanged else nil, 412 | -- ref = self._captureRef, 413 | }) 414 | ) 415 | end 416 | 417 | function VirtualizedSectionList:_getItem(props: Props, sections_: ReadOnlyArray?, index: number): Item? 418 | if not sections_ then 419 | return nil 420 | end 421 | local itemIdx = index - 1 422 | local i = 1 423 | local sections = sections_ :: ReadOnlyArray 424 | while i <= #sections do 425 | local section = sections[i] 426 | local sectionData = section.data 427 | local itemCount = props.getItemCount(sectionData) 428 | if itemIdx == 0 or itemIdx == itemCount + 1 then 429 | -- We intend for there to be overflow by one on both ends of the list. 430 | -- This will be for headers and footers. When returning a header or footer 431 | -- item the section itself is the item. 432 | return section 433 | elseif itemIdx <= itemCount then 434 | -- If we are in the bounds of the list's data then return the item. 435 | return props.getItem(sectionData, itemIdx) 436 | else 437 | itemIdx -= itemCount + 2 -- Add two for the header and footer 438 | end 439 | i += 1 440 | end 441 | return nil 442 | end 443 | 444 | function VirtualizedSectionList:_subExtractor(index: number): { 445 | section: SectionT, 446 | -- Key of the section or combined key for section + item 447 | key: string, 448 | -- Relative index within the section 449 | index: number?, 450 | -- True if this is the section header 451 | header: boolean?, 452 | leadingItem: Item?, 453 | leadingSection: SectionT?, 454 | trailingItem: Item?, 455 | trailingSection: SectionT?, 456 | }? 457 | local itemIndex = index 458 | local getItem, getItemCount, keyExtractor, sections 459 | do 460 | local ref = self.props 461 | getItem, getItemCount, keyExtractor, sections = ref.getItem, ref.getItemCount, ref.keyExtractor, ref.sections 462 | end 463 | local i = 1 -- ROBLOX deviation: added 1 to iteration index 464 | while i <= #sections do 465 | local section = sections[i] 466 | local sectionData = section.data 467 | local key = if Boolean.toJSBoolean(section.key) then tostring(section.key) else tostring(i) 468 | itemIndex -= 1 -- The section adds an item for the header 469 | if itemIndex > getItemCount(sectionData) + 1 then 470 | itemIndex -= getItemCount(sectionData) + 1 -- The section adds an item for the footer. 471 | elseif itemIndex == 0 then 472 | return { 473 | section = section, 474 | key = key .. ":header", 475 | index = nil, 476 | header = true, 477 | trailingSection = sections[i + 1], 478 | } 479 | elseif itemIndex == getItemCount(sectionData) + 1 then 480 | return { 481 | section = section, 482 | key = key .. ":footer", 483 | index = nil, 484 | header = false, 485 | trailingSection = sections[i + 1], 486 | } 487 | else 488 | local extractor = section.keyExtractor or keyExtractor or defaultKeyExtractor 489 | return { 490 | section = section, 491 | key = key .. ":" .. tostring(extractor(getItem(sectionData, itemIndex), itemIndex)), 492 | index = itemIndex, 493 | leadingItem = getItem(sectionData, itemIndex - 1), 494 | leadingSection = sections[i - 1], 495 | trailingItem = getItem(sectionData, itemIndex + 1), 496 | trailingSection = sections[i + 1], 497 | } 498 | end 499 | i += 1 500 | end 501 | return nil -- ROBLOX deviation: explicit return 502 | end 503 | 504 | function VirtualizedSectionList:_getSeparatorComponent( 505 | index: number, 506 | info_: Object?, 507 | listItemCount: number 508 | ): React_ComponentType? 509 | info_ = if info_ then info_ else self:_subExtractor(index) 510 | if not info_ then 511 | return nil 512 | end 513 | -- ROBLOX FIXME Luau: info is not nil. workaround 514 | local info = info_ :: Object 515 | local ItemSeparatorComponent = Boolean.toJSBoolean(info.section.ItemSeparatorComponent) 516 | and info.section.ItemSeparatorComponent 517 | or self.props.ItemSeparatorComponent 518 | local SectionSeparatorComponent = self.props.SectionSeparatorComponent 519 | local isLastItemInList = index == listItemCount -- ROBLOX deviation: added 1 to index 520 | local isLastItemInSection = info.index == self.props.getItemCount(info.section.data) 521 | if SectionSeparatorComponent and isLastItemInSection then 522 | return SectionSeparatorComponent 523 | end 524 | if ItemSeparatorComponent and not isLastItemInSection and not isLastItemInList then 525 | return ItemSeparatorComponent 526 | end 527 | return nil 528 | end 529 | 530 | type ItemWithSeparatorCommonProps = ReadOnly<{ 531 | leadingItem: Item?, 532 | leadingSection: Object?, 533 | section: Object, 534 | trailingItem: Item?, 535 | trailingSection: Object?, 536 | }> 537 | 538 | type ItemWithSeparatorProps = ReadOnly< 539 | ItemWithSeparatorCommonProps & { 540 | LeadingSeparatorComponent: React.ComponentType | nil, 541 | SeparatorComponent: React.ComponentType | nil, 542 | cellKey: string, 543 | index: number, 544 | item: Item, 545 | setSelfHighlightCallback: (cellKey: string, updateFn: ((boolean) -> ())?) -> (), 546 | setSelfUpdatePropsCallback: (cellKey: string, updateFn: ((boolean) -> ())?) -> (), 547 | prevCellKey: (string | nil)?, 548 | updateHighlightFor: (prevCellKey: string, value: boolean) -> (), 549 | updatePropsFor: (prevCellKey: string, value: Object) -> (), 550 | renderItem: Function, 551 | inverted: boolean, 552 | horizontal: boolean?, -- ROBLOX deviation: required to layout separators 553 | } 554 | > 555 | 556 | function ItemWithSeparator(props: ItemWithSeparatorProps): React_Node 557 | local LeadingSeparatorComponent, SeparatorComponent, cellKey, prevCellKey, setSelfHighlightCallback, updateHighlightFor, setSelfUpdatePropsCallback, updatePropsFor, item, index, section, inverted = 558 | props.LeadingSeparatorComponent, 559 | props.SeparatorComponent, -- this is the trailing separator and is associated with this item 560 | props.cellKey, 561 | props.prevCellKey, 562 | props.setSelfHighlightCallback, 563 | props.updateHighlightFor, 564 | props.setSelfUpdatePropsCallback, 565 | props.updatePropsFor, 566 | props.item, 567 | props.index, 568 | props.section, 569 | props.inverted 570 | 571 | -- ROBLOX deviation START: useState returns a list instead of an array 572 | local leadingSeparatorHiglighted, setLeadingSeparatorHighlighted = React.useState(false) 573 | 574 | local separatorHighlighted, setSeparatorHighlighted = React.useState(false) 575 | 576 | local leadingSeparatorProps, setLeadingSeparatorProps = React.useState({ 577 | leadingItem = props.leadingItem, 578 | leadingSection = props.leadingSection, 579 | section = props.section, 580 | trailingItem = props.item, 581 | trailingSection = props.trailingSection, 582 | LayoutOrder = -1, 583 | }) 584 | local separatorProps, setSeparatorProps = React.useState({ 585 | leadingItem = props.item, 586 | leadingSection = props.leadingSection, 587 | section = props.section, 588 | trailingItem = props.trailingItem, 589 | trailingSection = props.trailingSection, 590 | LayoutOrder = 1, 591 | }) 592 | -- ROBLOX deviation END 593 | 594 | React.useEffect(function() 595 | setSelfHighlightCallback(cellKey, setSeparatorHighlighted) 596 | setSelfUpdatePropsCallback(cellKey, setSeparatorProps :: any) 597 | return function() 598 | setSelfUpdatePropsCallback(cellKey, nil) 599 | setSelfHighlightCallback(cellKey, nil) 600 | end 601 | end, { cellKey, setSelfHighlightCallback :: any, setSeparatorProps :: any, setSelfUpdatePropsCallback :: any }) 602 | 603 | local separators = { 604 | highlight = function() 605 | setLeadingSeparatorHighlighted(true) 606 | setSeparatorHighlighted(true) 607 | if prevCellKey ~= nil then 608 | updateHighlightFor(prevCellKey, true) 609 | end 610 | end, 611 | unhighlight = function() 612 | setLeadingSeparatorHighlighted(false) 613 | setSeparatorHighlighted(false) 614 | if prevCellKey ~= nil then 615 | updateHighlightFor(prevCellKey, false) 616 | end 617 | end, 618 | updateProps = function(select: "leading" | "trailing", newProps: Shape) 619 | if select == "leading" then 620 | if LeadingSeparatorComponent ~= nil then 621 | setLeadingSeparatorProps(Object.assign({}, leadingSeparatorProps, newProps)) 622 | elseif prevCellKey ~= nil then 623 | -- update the previous item's separator 624 | updatePropsFor(prevCellKey, Object.assign({}, leadingSeparatorProps, newProps)) 625 | end 626 | elseif select == "trailing" and SeparatorComponent ~= nil then 627 | setSeparatorProps(Object.assign({}, separatorProps, newProps)) 628 | end 629 | end, 630 | } 631 | local element = props.renderItem({ 632 | item = item, 633 | index = index, 634 | section = section, 635 | separators = separators, 636 | }) 637 | local leadingSeparator = LeadingSeparatorComponent ~= nil 638 | and React.createElement( 639 | LeadingSeparatorComponent, 640 | Object.assign({ highlighted = leadingSeparatorHiglighted }, leadingSeparatorProps) 641 | ) 642 | local separator = SeparatorComponent ~= nil 643 | and React.createElement( 644 | SeparatorComponent, 645 | Object.assign({ highlighted = separatorHighlighted }, separatorProps) 646 | ) 647 | return if leadingSeparator or separator 648 | then React.createElement( 649 | View, 650 | { 651 | Size = if props.horizontal then UDim2.new(0, 0, 1, 0) else UDim2.new(1, 0, 0, 0), 652 | AutomaticSize = if props.horizontal then Enum.AutomaticSize.X else Enum.AutomaticSize.Y, 653 | }, 654 | React.createElement("UIListLayout", { 655 | SortOrder = Enum.SortOrder.LayoutOrder, 656 | FillDirection = if props.horizontal then Enum.FillDirection.Horizontal else Enum.FillDirection.Vertical, 657 | }), 658 | if inverted == false then leadingSeparator else separator, 659 | element, 660 | if inverted == false then separator else leadingSeparator 661 | ) 662 | else element 663 | end 664 | --[[ $FlowFixMe[class-object-subtyping] added when improving typing for this 665 | * parameters ]] 666 | -- $FlowFixMe[method-unbinding] 667 | return VirtualizedSectionList :: React_AbstractComponent< 668 | React_ElementConfig, 669 | ReadOnly<{ 670 | getListRef: (self: VirtualizedSectionList) -> React_ElementRef?, 671 | scrollToLocation: (self: VirtualizedSectionList, params: ScrollToLocationParamsType) -> (), 672 | }> 673 | > 674 | -------------------------------------------------------------------------------- /src/StyleSheet/StyleSheet.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX note: no upstream 2 | -- ROBLOX comment: Partially converted from https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/StyleSheet/StyleSheet.js 3 | --[[* 4 | * Copyright (c) Meta Platforms, Inc. and affiliates. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE file in the root directory of this source tree. 8 | * 9 | * @flow 10 | * @format 11 | ]] 12 | local Packages = script.Parent.Parent.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | local Array = LuauPolyfill.Array 15 | local Object = LuauPolyfill.Object 16 | type Array = LuauPolyfill.Array 17 | type Object = LuauPolyfill.Object 18 | type ReadOnly = T 19 | type ReadOnlyArray = Array 20 | 21 | local exports = {} 22 | local function compose(style1: T?, style2: T?): T | ReadOnlyArray | nil 23 | if style1 ~= nil and style2 ~= nil then 24 | return { style1, style2 } :: ReadOnlyArray 25 | else 26 | return if style1 ~= nil then style1 else style2 27 | end 28 | end 29 | exports.compose = compose 30 | 31 | local function create(obj: Object): ReadOnly 32 | -- TODO: This should return S as the return type. But first, 33 | -- we need to codemod all the callsites that are typing this 34 | -- return value as a number (even though it was opaque). 35 | if _G.__DEV__ then 36 | Array.forEach(Object.keys(obj), function(key) 37 | if obj[key] then 38 | Object.freeze(obj[key]) 39 | end 40 | end) 41 | end 42 | return obj 43 | end 44 | exports.create = create 45 | 46 | return exports 47 | -------------------------------------------------------------------------------- /src/Utilities/codegenNativeCommands.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Utilities/codegenNativeCommands.js 2 | --[[ 3 | Copyright (c) Meta Platforms, Inc. and affiliates. 4 | 5 | This source code is licensed under the MIT license found in the 6 | LICENSE file in the root directory of this source tree. 7 | 8 | @format 9 | @flow strict-local 10 | ]] 11 | local Packages = script.Parent.Parent.Parent 12 | local LuauPolyfill = require(Packages.LuauPolyfill) 13 | type Array = LuauPolyfill.Array 14 | 15 | --[[ ROBLOX upstream: Dependency not implemented locally 16 | import {dispatchCommand} from '../../Libraries/Renderer/shims/ReactNative'; 17 | ]] 18 | local dispatchCommand = function(...) 19 | error("Not implemented. Dependencies used upstream aren't implemented") 20 | end 21 | 22 | -- ROBLOX deviation START: Unsupported types - Replace with actual types when available 23 | type Readonly = T 24 | type ReadonlyArray = Array 25 | type Options = Readonly<{ 26 | supportedCommands: ReadonlyArray, 27 | }> 28 | type Keys = any 29 | -- ROBLOX deviation END 30 | 31 | local codegenNativeCommands = function(options: Options>): T 32 | local commandObj = {} 33 | 34 | for _, command in ipairs(options.supportedCommands) do 35 | commandObj[command] = function(ref, ...) 36 | dispatchCommand(ref, command, ...) 37 | end 38 | end 39 | 40 | return (commandObj :: any) :: T 41 | end 42 | 43 | return { 44 | default = codegenNativeCommands, 45 | } 46 | -------------------------------------------------------------------------------- /src/Utilities/differ/deepDiffer.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Utilities/differ/deepDiffer.js 2 | --[[ 3 | Copyright (c) Meta Platforms, Inc. and affiliates. 4 | 5 | This source code is licensed under the MIT license found in the 6 | LICENSE file in the root directory of this source tree. 7 | ]] 8 | local srcWorkspace = script.Parent.Parent.Parent 9 | local Packages = srcWorkspace.Parent 10 | local LuauPolyfill = require(Packages.LuauPolyfill) 11 | local Array = LuauPolyfill.Array 12 | local Boolean = LuauPolyfill.Boolean 13 | 14 | local logListeners 15 | 16 | type LogListeners = { 17 | onDifferentFunctionsIgnored: (nameOne: string?, nameTwo: string?) -> (), 18 | } 19 | 20 | type Options = { unsafelyIgnoreFunctions: boolean? } 21 | 22 | local function unstable_setLogListeners(listeners: LogListeners?) 23 | logListeners = listeners 24 | end 25 | 26 | --[[ 27 | * @returns {bool} true if different, false if equal 28 | ]] 29 | local function deepDiffer(one: any, two: any, maxDepthOrOptions_: (Options | number)?, maybeOptions: Options?): boolean 30 | local maxDepthOrOptions = if maxDepthOrOptions_ ~= nil then maxDepthOrOptions_ else -1 31 | 32 | local options = if typeof(maxDepthOrOptions) == "number" then maybeOptions else maxDepthOrOptions 33 | 34 | local maxDepth = if typeof(maxDepthOrOptions) == "number" then maxDepthOrOptions else -1 35 | 36 | if maxDepth == 0 then 37 | return true 38 | end 39 | 40 | if one == two then 41 | -- Short circuit on identical object references instead of traversing them. 42 | return false 43 | end 44 | if typeof(one) == "function" and typeof(two) == "function" then 45 | -- We consider all functions equal unless explicitly configured otherwise 46 | -- ROBLOX deviation: Added this to handle checking for optional members in options 47 | local optionsContainsUnsafelyIgnoreFunctions = if options == nil 48 | then false 49 | else options["unsafelyIgnoreFunctions"] ~= nil 50 | 51 | local unsafelyIgnoreFunctions = if options ~= nil and optionsContainsUnsafelyIgnoreFunctions 52 | then options.unsafelyIgnoreFunctions 53 | else nil 54 | 55 | if unsafelyIgnoreFunctions == nil then 56 | if 57 | logListeners ~= nil 58 | and Boolean.toJSBoolean(logListeners.onDifferentFunctionsIgnored) 59 | and (not Boolean.toJSBoolean(options) or not optionsContainsUnsafelyIgnoreFunctions) 60 | then 61 | logListeners.onDifferentFunctionsIgnored(debug.info(one, "n"), debug.info(two, "n")) 62 | end 63 | unsafelyIgnoreFunctions = true 64 | end 65 | return not unsafelyIgnoreFunctions 66 | end 67 | if typeof(one) ~= "table" or one == nil then 68 | -- Primitives can be directly compared 69 | return one ~= two 70 | end 71 | if typeof(two) ~= "table" or two == nil then 72 | -- We know they are different because the previous case would have triggered 73 | -- otherwise. 74 | return true 75 | end 76 | 77 | if Array.isArray(one) ~= Array.isArray(two) then 78 | return true 79 | end 80 | if Array.isArray(one) then 81 | -- We know two is also an array because the constructors are equal 82 | if #two ~= #one then 83 | return true 84 | end 85 | 86 | for ii = 1, #one do 87 | if deepDiffer(one[ii], two[ii], maxDepth - 1, options) then 88 | return true 89 | end 90 | end 91 | else 92 | for key in pairs(one) do 93 | if deepDiffer(one[key], two[key], maxDepth - 1, options) then 94 | return true 95 | end 96 | end 97 | 98 | for key in pairs(two) do 99 | if one[key] == nil and two[key] ~= nil then 100 | return true 101 | end 102 | end 103 | end 104 | return false 105 | end 106 | 107 | local exports = setmetatable({ 108 | unstable_setLogListeners = unstable_setLogListeners, 109 | }, { 110 | __call = function( 111 | _self, 112 | one: any, 113 | two: any, 114 | maxDepthOrOptions_: (Options | number)?, 115 | maybeOptions: Options? 116 | ): boolean 117 | return deepDiffer(one, two, maxDepthOrOptions_, maybeOptions) 118 | end, 119 | }) 120 | 121 | return exports 122 | -------------------------------------------------------------------------------- /src/Utilities/infoLog.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Utilities/infoLog.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @format 9 | * @flow strict 10 | ]] 11 | local srcWorkspace = script.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | local LuauPolyfill = require(Packages.LuauPolyfill) 14 | local console = LuauPolyfill.console 15 | --[[* 16 | * Intentional info-level logging for clear separation from ad-hoc console debug logging. 17 | ]] 18 | local function infoLog(...: any): () 19 | return console.log(...) 20 | end 21 | 22 | return infoLog 23 | -------------------------------------------------------------------------------- /src/Utilities/setAndForwardRef.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react-native/blob/v0.68.0-rc.2/Libraries/Utilities/setAndForwardRef.js 2 | --[[* 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | * @format 9 | * @flow 10 | ]] 11 | local srcWorkspace = script.Parent.Parent 12 | local Packages = srcWorkspace.Parent 13 | 14 | -- ROBLOX deviation: unavailable types 15 | type ReadOnly = T 16 | 17 | local React = require(Packages.React) 18 | type ElementRef = React.ElementRef 19 | type Ref = React.Ref 20 | 21 | type Args = ReadOnly<{ 22 | getForwardedRef: () -> Ref | nil, 23 | setLocalRef: (ref: ElementRef) -> any, 24 | }> 25 | 26 | --[[* 27 | * This is a helper function for when a component needs to be able to forward a ref 28 | * to a child component, but still needs to have access to that component as part of 29 | * its implementation. 30 | * 31 | * Its main use case is in wrappers for native components. 32 | * 33 | * Usage: 34 | * 35 | * class MyView extends React.Component { 36 | * _nativeRef = null; 37 | * 38 | * _setNativeRef = setAndForwardRef({ 39 | * getForwardedRef: () => this.props.forwardedRef, 40 | * setLocalRef: ref => { 41 | * this._nativeRef = ref; 42 | * }, 43 | * }); 44 | * 45 | * render() { 46 | * return ; 47 | * } 48 | * } 49 | * 50 | * const MyViewWithRef = React.forwardRef((props, ref) => ( 51 | * 52 | * )); 53 | * 54 | * module.exports = MyViewWithRef; 55 | ]] 56 | 57 | local function setAndForwardRef(ref_: Args): (ref: ElementRef) -> () 58 | local getForwardedRef, setLocalRef = ref_.getForwardedRef, ref_.setLocalRef 59 | return function(ref: ElementRef) 60 | local forwardedRef = getForwardedRef() 61 | setLocalRef(ref) 62 | 63 | -- Forward to user ref prop (if one has been specified) 64 | if typeof(forwardedRef) == "function" then 65 | -- Handle function-based refs. String-based refs are handled as functions. 66 | forwardedRef(ref) 67 | elseif typeof(forwardedRef) == "table" then 68 | -- Handle createRef-based refs 69 | forwardedRef.current = ref 70 | end 71 | end 72 | end 73 | 74 | return setAndForwardRef 75 | -------------------------------------------------------------------------------- /src/init.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX note: no upstream 2 | --[[ 3 | * Copyright (c) Roblox Corporation. All rights reserved. 4 | * Licensed under the MIT License (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://opensource.org/licenses/MIT 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | ]] 16 | local Components = script.Components 17 | local Lists = script.Lists 18 | local ScrollView = require(Components.ScrollView.ScrollView) 19 | local VirtualizedList = require(Lists.VirtualizedList) 20 | local SectionList = require(Lists.SectionList).default 21 | local FlatList = require(Lists.FlatList) 22 | local View = require(Components.View.View) 23 | local ViewabilityHelper = require(Lists.ViewabilityHelper) 24 | local Hooks = require(Lists.Hooks) 25 | local AnimatedFlatList = require(Lists.AnimatedFlatList) 26 | 27 | export type ViewabilityConfigCallbackPair = ViewabilityHelper.ViewabilityConfigCallbackPair 28 | export type ViewabilityConfig = ViewabilityHelper.ViewabilityConfig 29 | export type AnimatedFlatListProps = AnimatedFlatList.Props 30 | 31 | return { 32 | ScrollView = ScrollView, 33 | VirtualizedList = VirtualizedList, 34 | SectionList = SectionList, 35 | FlatList = FlatList, 36 | View = View, 37 | AnimatedFlatList = AnimatedFlatList, 38 | Hooks = Hooks, 39 | } 40 | -------------------------------------------------------------------------------- /src/jsUtils/invariant.luau: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/facebook/react/blob/42c3c967d1e4ca4731b47866f2090bc34caa086c/packages/shared/invariant.js 2 | --[[* 3 | * Copyright (c) Facebook, Inc. and its affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | ]] 9 | 10 | --[[* 11 | * Use invariant() to assert state which your program assumes to be true. 12 | * 13 | * Provide sprintf-style format (only %s is supported) and arguments 14 | * to provide information about what broke and what you were 15 | * expecting. 16 | * 17 | * The invariant message will be stripped in production, but the invariant 18 | * will remain to ensure logic does not differ in production. 19 | ]] 20 | local Packages = script.Parent.Parent.Parent 21 | local LuauPolyfill = require(Packages.LuauPolyfill) 22 | local Error = LuauPolyfill.Error 23 | 24 | local function invariant(condition, format, ...) 25 | -- ROBLOX TODO: we should encapsulate all formatting compatibility here, 26 | -- rather than spreading workarounds throughout the codebase, eg this 27 | -- should print an array without the need for a table.concat on the consumer side 28 | if not condition then 29 | error(Error(string.format(format, ...))) 30 | end 31 | end 32 | 33 | return invariant 34 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 120 2 | line_endings = "Unix" 3 | indent_type = "Tabs" 4 | indent_width = 4 5 | quote_style = "AutoPreferDouble" 6 | call_parentheses = "Always" 7 | collapse_simple_statement = "Never" 8 | 9 | [sort_requires] 10 | enabled = false 11 | -------------------------------------------------------------------------------- /wally.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Wally. 2 | # It is not intended for manual editing. 3 | registry = "test" 4 | 5 | [[package]] 6 | name = "core-packages/boolean" 7 | version = "1.2.3" 8 | dependencies = [["Number", "core-packages/number@1.2.3"]] 9 | 10 | [[package]] 11 | name = "core-packages/collections" 12 | version = "1.2.3" 13 | dependencies = [["ES7Types", "core-packages/es7-types@1.2.3"], ["InstanceOf", "core-packages/instance-of@1.2.3"]] 14 | 15 | [[package]] 16 | name = "core-packages/console" 17 | version = "1.2.3" 18 | dependencies = [["Collections", "core-packages/collections@1.2.3"]] 19 | 20 | [[package]] 21 | name = "core-packages/es7-types" 22 | version = "1.2.3" 23 | dependencies = [] 24 | 25 | [[package]] 26 | name = "core-packages/instance-of" 27 | version = "1.2.3" 28 | dependencies = [] 29 | 30 | [[package]] 31 | name = "core-packages/luau-polyfill" 32 | version = "1.2.3" 33 | dependencies = [["Boolean", "core-packages/boolean@1.2.3"], ["Collections", "core-packages/collections@1.2.3"], ["Console", "core-packages/console@1.2.3"], ["ES7Types", "core-packages/es7-types@1.2.3"], ["InstanceOf", "core-packages/instance-of@1.2.3"], ["Math", "core-packages/math@1.2.3"], ["Number", "core-packages/number@1.2.3"], ["String", "core-packages/string@1.2.3"], ["Symbol", "core-packages/symbol@1.1.0"], ["Timers", "core-packages/timers@1.2.3"]] 34 | 35 | [[package]] 36 | name = "core-packages/math" 37 | version = "1.2.3" 38 | dependencies = [] 39 | 40 | [[package]] 41 | name = "core-packages/number" 42 | version = "1.2.3" 43 | dependencies = [] 44 | 45 | [[package]] 46 | name = "core-packages/react" 47 | version = "17.0.1-rc.19" 48 | dependencies = [["LuauPolyfill", "core-packages/luau-polyfill@1.2.3"], ["Shared", "core-packages/shared@17.0.1-rc.19"]] 49 | 50 | [[package]] 51 | name = "core-packages/shared" 52 | version = "17.0.1-rc.19" 53 | dependencies = [["LuauPolyfill", "core-packages/luau-polyfill@1.2.3"]] 54 | 55 | [[package]] 56 | name = "core-packages/string" 57 | version = "1.2.3" 58 | dependencies = [["ES7Types", "core-packages/es7-types@1.2.3"], ["Number", "core-packages/number@1.2.3"]] 59 | 60 | [[package]] 61 | name = "core-packages/symbol" 62 | version = "1.1.0" 63 | dependencies = [] 64 | 65 | [[package]] 66 | name = "core-packages/timers" 67 | version = "1.2.3" 68 | dependencies = [["Collections", "core-packages/collections@1.2.3"]] 69 | 70 | [[package]] 71 | name = "evaera/promise" 72 | version = "4.0.0" 73 | dependencies = [] 74 | 75 | [[package]] 76 | name = "jsdotlua/boolean" 77 | version = "1.2.3" 78 | dependencies = [["Number", "jsdotlua/number@1.2.3"]] 79 | 80 | [[package]] 81 | name = "jsdotlua/collections" 82 | version = "1.2.3" 83 | dependencies = [["ES7Types", "jsdotlua/es7-types@1.2.3"], ["InstanceOf", "jsdotlua/instance-of@1.2.3"]] 84 | 85 | [[package]] 86 | name = "jsdotlua/console" 87 | version = "1.2.3" 88 | dependencies = [["Collections", "jsdotlua/collections@1.2.3"]] 89 | 90 | [[package]] 91 | name = "jsdotlua/es7-types" 92 | version = "1.2.3" 93 | dependencies = [] 94 | 95 | [[package]] 96 | name = "jsdotlua/instance-of" 97 | version = "1.2.3" 98 | dependencies = [] 99 | 100 | [[package]] 101 | name = "jsdotlua/luau-polyfill" 102 | version = "1.2.3" 103 | dependencies = [["Boolean", "jsdotlua/boolean@1.2.3"], ["Collections", "jsdotlua/collections@1.2.3"], ["Console", "jsdotlua/console@1.2.3"], ["ES7Types", "jsdotlua/es7-types@1.2.3"], ["InstanceOf", "jsdotlua/instance-of@1.2.3"], ["Math", "jsdotlua/math@1.2.3"], ["Number", "jsdotlua/number@1.2.3"], ["String", "jsdotlua/string@1.2.3"], ["Symbol", "jsdotlua/symbol@1.0.0"], ["Timers", "jsdotlua/timers@1.2.3"]] 104 | 105 | [[package]] 106 | name = "jsdotlua/math" 107 | version = "1.2.3" 108 | dependencies = [] 109 | 110 | [[package]] 111 | name = "jsdotlua/number" 112 | version = "1.2.3" 113 | dependencies = [] 114 | 115 | [[package]] 116 | name = "jsdotlua/string" 117 | version = "1.2.3" 118 | dependencies = [["ES7Types", "jsdotlua/es7-types@1.2.3"], ["Number", "jsdotlua/number@1.2.3"]] 119 | 120 | [[package]] 121 | name = "jsdotlua/symbol" 122 | version = "1.0.0" 123 | dependencies = [] 124 | 125 | [[package]] 126 | name = "jsdotlua/timers" 127 | version = "1.2.3" 128 | dependencies = [["Collections", "jsdotlua/collections@1.2.3"]] 129 | 130 | [[package]] 131 | name = "jsdotlua/virtualized-list" 132 | version = "1.3.0" 133 | dependencies = [["Flipper", "reselim/flipper@2.0.0"], ["LuauPolyfill", "jsdotlua/luau-polyfill@1.2.3"], ["Promise", "evaera/promise@4.0.0"], ["React", "core-packages/react@17.0.1-rc.19"]] 134 | 135 | [[package]] 136 | name = "reselim/flipper" 137 | version = "2.0.0" 138 | dependencies = [] 139 | -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jsdotlua/virtualized-list" 3 | version = "1.3.6" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | realm = "shared" 6 | 7 | [dependencies] 8 | LuauPolyfill = 'jsdotlua/luau-polyfill@1.2.3' 9 | Flipper = "reselim/flipper@2.0.0" 10 | Promise = 'evaera/promise@4.0.0' 11 | React = 'jsdotlua/react@17.0.2' 12 | --------------------------------------------------------------------------------