03. `Slot:List`로 긴 목록 다루기 — 재활용과 윈도잉
대상 독자: 수백~수만 개짜리 목록을
ScrollingFrame에 그려야 하는 개발자 다루는 개념:Slot():List,updateFn의 다섯 인자와 세 갈래 처분,userdata,offset,Blocker재활용과 윈도잉은 서로 다른 행에 걸립니다: 재활용은 화면에 계속 남아 있는 키에만 적용되고, 윈도우 밖으로 나가 키가 사라진 행은 재활용이 아니라 파괴(또는q.Detach로 홀드)입니다(§7).
1. 해결하려는 문제
섹션 제목: “1. 해결하려는 문제”ScrollingFrame 안에 항목 수만큼 Instance를 만들면 생성 비용과 레이아웃
비용이 같이 늘어납니다. 해법은 둘 — 재활용(목록이 바뀌어도 살아남은
항목은 다시 만들지 않는다)과 윈도잉(보이는 구간+여유분만 만든다)입니다.
둘 다의 토대는 Slot():List입니다. 다만 quad는 위치를 대신 정해주지
않습니다 — index와 offset을 넘겨줄 뿐, 그것을 LayoutOrder에 쓸지
Position에 쓸지는 전적으로 updateFn을 쓰는 사람의 몫입니다(3절).
-- 설치 경로는 프로젝트 구성에 따라 다르다(00-installation 참고)local Quad = require(<quad-base 모듈 경로>)local QuadRoblox = require(<quad-roblox 모듈 경로>).QuadRobloxlocal QuadTypes = require(<quad-types 모듈 경로>)local q = Quad:UseProvider(QuadRoblox) -- quad-roblox 백엔드 설치: D/Tween/Animate/OnChange가 생긴다local D = q.D2. Slot:List 계약
섹션 제목: “2. Slot:List 계약”Slot():List(data, updateFn, keyFn?, opts?)data— 평범한 배열, 또는 배열을 담은State. 그 외에는 에러입니다 (Slot:List: data must be a plain array or a State of one (got ...)).State면 값이 바뀔 때마다 재조정(reconcile)이 돕니다.keyFn(item, i)— 항목의 신원. 생략하면 배열 인덱스가 키입니다.nil을 돌려주면Slot:List: keyFn returned nil for item #N, 키가 겹치면Slot:List: duplicate key ...에러입니다.opts.Owned = false— 이 Slot이 요소를 파괴하지 않습니다(목록에서 빠질 때 언마운트만). 밖에서 만들어 넘긴 Instance를 목록에 태울 때 씁니다.
updateFn의 시그니처가 핵심입니다
섹션 제목: “updateFn의 시그니처가 핵심입니다”updateFn(item, index, offset, prev, ud) -> (result, ud)| 인자 | 뜻 |
|---|---|
item |
이번 사이클의 데이터 항목. 키가 이번 데이터에서 사라졌으면 q.KeyGone |
index |
이 요소가 차지할 Slot 안의 물리 위치(1부터). 원본 배열의 인덱스가 아닙니다 — 요소 하나가 자식 하나면 순번과 같고, 앞선 요소가 중첩 Slot이면 그 길이만큼 건너뜁니다 |
offset |
형제 누적합 Source — 이 Slot 앞에 몇 개의 물리 자식이 있는지 |
prev |
이 키로 직전에 마운트돼 있던 요소(없으면 nil) |
ud |
이 키에 대해 직전 호출이 돌려준 userdata(자유 값) |
반환값은 두 개입니다 — (요소, userdata). 이 둘의 조합이 세 갈래의 처분을
만듭니다.
| 무엇을 반환하나 | 무슨 일이 일어나나 |
|---|---|
prev 그대로 |
그 자리에 계속 둡니다. 마운트도 파괴도 없는 가장 싼 경로 |
| 새 값 | prev가 있었다면 파괴되고(Owned = false면 언마운트만) 새 값이 그 자리를 대신합니다 |
nil 또는 q.None |
prev가 있었다면 파괴됩니다(Owned = false면 언마운트만) |
q.Detach |
파괴하지 않고 트리 밖에 붙들어 둡니다. 그 키가 다시 오면 같은 요소가 그대로 재마운트됩니다 |
item == q.KeyGone 사이클에서는 nil/q.None(파괴)과 q.Detach(홀드)만
허용됩니다 — 새 값을 돌려주면
Slot:List: KeyGone accepts only nil/None (destroy) or Detach (hold) 에러입니다.
재활용은 prev를 돌려주는 갈래에서만 일어납니다. 그래서 항목마다 바뀌는
값(라벨 텍스트, 위치 등)은 userdata 안에 Source로 넣어두고, 이 갈래에서
그 Source만 :Set 합니다.
3. 순서와 위치는 호출자가 정한다
섹션 제목: “3. 순서와 위치는 호출자가 정한다”Slot은 LayoutOrder라는 이름을 알지 못합니다. 자동으로 넣어주는 안은
검토 후 기각됐습니다 — 컴포넌트가 스스로 지정한 LayoutOrder를 Slot이 조용히
덮어쓰게 되고(“매직 없이 명시적”이라는 기조와 충돌), updateFn이 동적 요소를
전부 다룬다는 원래 설계도 깨지기 때문입니다.
UIListLayout으로 세로 배치를 할 때의 정본 관용구는 이렇습니다.
local function updateFn( item: any, index: number, offset: QuadTypes.State<number>, prev: any, ud: any): (any, any) if item == q.KeyGone then return nil, ud -- 사라진 키 — 파괴 end
if not prev then -- 새 요소: 이전 Source를 재사용하지 말고 처음부터 올바른 값으로 만든다 local layoutOrder = q.Source(index) local order: QuadTypes.State<number> = layoutOrder:With(offset):Compute(function(i) return i:Get() + offset:Get() end) return D.Frame { LayoutOrder = order, -- ... }, { layoutOrder = layoutOrder } end
-- 재활용: 실제로 바뀔 때만 Set local layoutOrder = ud.layoutOrder if layoutOrder:Get() ~= index then layoutOrder:Set(index) end return prev, udend⚠️
:With(offset)으로 모은 값은Compute콜백에 포지셔널로 넘어오지 않습니다. 두 번째 자리에 오는 것은 직전 계산 결과(previous)입니다 — 위처럼 클로저로offset:Get()을 직접 읽으세요.
:Compute를 props 테이블 안에 인라인으로 쓰지 않고 위처럼 타입 붙인 지역 변수로 빼는 이유는 에디터 타입 검사입니다 — 함수 인자로 넘기는 테이블 리터럴 안에서는 무주석 콜백 파라미터가 풀리지 않습니다.
4. 윈도잉 — 보이는 구간만 그리기
섹션 제목: “4. 윈도잉 — 보이는 구간만 그리기”윈도잉을 하면 목록의 일부만 자식으로 존재하므로 UIListLayout이 전체 스크롤
높이를 만들어낼 수 없습니다. 그래서 윈도우 방식에서는 LayoutOrder가 아니라
항목의 절대 인덱스로 Position을 직접 계산하고, 스크롤 범위는
CanvasSize로 고정합니다. 3절의 LayoutOrder 관용구는 이 레시피에선 쓰이지
않습니다 — 둘 중 하나를 고르세요.
local ROW = 50local BUFFER = 3
local function VirtualList(props: { items: { { id: string, label: string } } }) local items = props.items local scrollY = q.Source(0) local viewportHeight = q.Source(600)
-- 보이는 구간을 반응형으로 잘라낸다(후행 의존성: fn(self, previous, ...deps)) local window = scrollY:Compute(function(y, _previous, height) local first = math.floor(y:Get() / ROW) + 1 local count = math.ceil(height:Get() / ROW) + 1 local startIndex = math.max(1, first - BUFFER) local endIndex = math.min(#items, first + count + BUFFER) local slice = {} for i = startIndex, endIndex do table.insert(slice, { item = items[i], absIndex = i }) end return slice end, viewportHeight)
local rows = q.Slot<<Instance>>():List(window, function(entry: any, _index, _offset, prev, ud): (any, any) if entry == q.KeyGone then return nil, ud end if prev then -- 화면에 남아 있는 행: 절대 위치만 갱신하고 Instance는 재활용 if ud.abs:Get() ~= entry.absIndex then ud.abs:Set(entry.absIndex) end return prev, ud end local abs = q.Source(entry.absIndex) local position: QuadTypes.State<UDim2> = abs:Compute(function(a) return UDim2.new(0, 0, 0, (a:Get() - 1) * ROW) end) local row = D.Frame { Size = UDim2.new(1, 0, 0, ROW), Position = position, BackgroundTransparency = 1,
D.TextLabel { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, TextXAlignment = Enum.TextXAlignment.Left, Text = entry.item.label, }, } return row, { abs = abs } end, function(entry) return entry.item.id end) -- 생성자의 제네릭은 추론되지 않는다 — `q.Slot<<Instance>>()`처럼 명시 타입 인자로 준다
return D.ScrollingFrame { Size = UDim2.fromScale(1, 1), CanvasSize = UDim2.new(0, 0, 0, #items * ROW), ScrollBarThickness = 6,
q.OnChange("CanvasPosition", function(pos: Vector2) scrollY:Set(pos.Y) end), q.OnChange("AbsoluteWindowSize", function(size: Vector2) viewportHeight:Set(size.Y) end),
rows, }endq.OnChange(name, fn)는 배열 부분에 놓는 디스크립터입니다(해시 키가
아닙니다). 콜백은 엔진 인자만 받습니다.
5. 스크롤 이벤트 폭주 누그러뜨리기 — Blocker
섹션 제목: “5. 스크롤 이벤트 폭주 누그러뜨리기 — Blocker”CanvasPosition은 한 프레임에도 여러 번 바뀔 수 있습니다. 여러 변경을 한 번의
전파로 접으려면 Blocker를 state:Apply(blocker)로 붙여 게이트된 State를
만들고, 목록은 그 게이트된 State를 구독하게 합니다.
local rawScrollY = q.Source(0)local scrollGate = q.Blocker()local scrollY = rawScrollY:Apply(scrollGate) -- 게이트된 노드 — 아래 계산은 이걸 본다
-- 묶어서 밀어 넣을 구간scrollGate:On()rawScrollY:Set(120)rawScrollY:Set(180)scrollGate:Off() -- 여기서 정확히 한 번 전파된다(OffWithoutEmit이면 버려진다)blocker를 그냥 만들어 두기만 하면 아무것도 막히지 않습니다 —:Apply로 붙인 State만 게이트됩니다.- 같은
Blocker를 중첩해서On두 번,Off한 번 하는 식은 지원하지 않습니다(IsBlocked는 카운터가 아니라 단순 불리언). 겹치는 배치가 필요하면 배치마다 새Blocker를 만드세요.
6. 엔진 사실: nativeMove/nativeSwap은 의도적 no-op
섹션 제목: “6. 엔진 사실: nativeMove/nativeSwap은 의도적 no-op”Roblox에서는 형제의 물리적 순서가 렌더 순서를 정하지 않습니다 — 순서는
UIListLayout의 SortOrder/LayoutOrder나 ZIndex가 정합니다. 그래서
quad-roblox 백엔드는 재정렬 op를 일부러 아무 일도 하지 않게 구현합니다.
-- quad-roblox/src/EngineOps.luaulocal function nativeMove(_target, _fromOffset, _elements, _toOffset) -- no-op on purpose — order is bookkeeping, not physical, on Robloxend기본 합성 폴백을 그대로 뒀다면 .Parent를 두 번 쓰며 떼었다 붙이게 되고
(AncestryChanged 재발화), Roblox에서는 물리적으로 아무것도 바뀌지 않는
재정렬을 위해 그 비용을 치르게 됩니다. quad-base는 여전히 정확한 슬롯 인덱스
부기를 유지합니다 — DOM처럼 자식 순서가 실제 의미를 갖는 백엔드가 그 부기를
쓰기 때문입니다.
7. 확인 방법과 이 레시피가 안 해주는 것
섹션 제목: “7. 확인 방법과 이 레시피가 안 해주는 것”확인 — 개발자 콘솔(F9) → Memory의 Instances 수가 목록 길이가 아니라
윈도우 크기에 비례해 머무는지, 그리고 updateFn의 “새로 만드는” 갈래에
카운터를 넣어 살아남은 키가 다시 만들어지지 않는지 봅니다.
안 해주는 것
- 행 높이 측정이 없습니다. 위 예제는 모든 행이
ROW로 같은 높이라고 가정합니다 — 가변 높이는 직접 누적 높이를 관리해야 합니다. - 윈도우 밖으로 나간 행은 재활용되지 않습니다. 키가 사라지면 파괴(또는
q.Detach로 홀드)이고, 재활용은 “화면에 계속 남아 있는 키”에만 적용됩니다. - 스크롤 이벤트 자체의 스로틀링은 없습니다. 5절의
Blocker는 사용자가 직접 구간을 열고 닫는 도구이지 자동 프레임 병합기가 아닙니다.