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