03. `Slot:List`로 긴 목록 다루기 — 재활용과 윈도잉
대상 독자: 수백~수만 개짜리 목록을
ScrollingFrame에 그려야 하는 개발자 다루는 개념:LayoutOrder/Position바인딩, 윈도잉,userdata,Throttle먼저:Slot:List의 기본(계약·재사용·KeyGone·Detach)은 시작하기 14. 목록 만들기에서 배웁니다. 이 문서는 그 위에서 긴 목록을 다루는 법만 봅니다. 재활용과 윈도잉은 서로 다른 행에 걸립니다: 재활용은 화면에 계속 남아 있는 키에만 적용되고, 윈도우 밖으로 나가 키가 사라진 행은 재활용이 아니라 파괴(또는q.Detach로 홀드)입니다(§6).
1. 해결하려는 문제
섹션 제목: “1. 해결하려는 문제”ScrollingFrame 안에 항목 수만큼 Instance를 만들면 생성 비용과 레이아웃
비용이 같이 늘어납니다. 해법은 둘 — 재활용(목록이 바뀌어도 살아남은
항목은 다시 만들지 않는다)과 윈도잉(보이는 구간+여유분만 만든다)입니다.
둘 다의 토대는 Slot():List입니다. 다만 quad는 위치를 대신 정해주지
않습니다 — ctx.Index와 ctx.Offset을 넘겨줄 뿐, 그것을 LayoutOrder에 쓸지
Position에 쓸지는 전적으로 updateFn을 쓰는 사람의 몫입니다(3절).
-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)local q = require("@game/ReplicatedStorage/Client/UI/Quad")2. 긴 목록에서 특히 중요한 계약 셋
섹션 제목: “2. 긴 목록에서 특히 중요한 계약 셋”전체 계약(ctx 필드 다섯과 네 갈래 반환)은 시작하기 14와 레퍼런스: Slot에 있습니다. 여기서는 목록이 길어질 때 비용을 가르는 셋만 짚습니다.
Slot():List(data, updateFn, keyFn?, opts?)updateFn(ctx) -> (result, userdata)-- ctx = { Item, Index, Offset, Prev, UserData } (호출마다 새 테이블)keyFn(item, i)가 재활용을 결정합니다. 항목의 신원이 안정적이어야ctx.Prev가 넘어오고,ctx.Prev를 그대로 돌려주는 갈래가 마운트도 파괴도 없는 가장 싼 경로입니다. 생략하면 배열 인덱스가 키인데, 그러면 목록이 밀릴 때마다 신원이 어긋나 전부 새로 만들어집니다.- 항목마다 바뀌는 값은
userdata에Source로 넣어 둡니다(다음 호출의ctx.UserData로 돌아옵니다). 재활용 갈래에서는 인스턴스를 다시 만들 수 없으니, 라벨 텍스트나 위치는 그Source만:Set합니다. opts.OwnsElements = false— 이 Slot이 요소를 파괴하지 않습니다(목록에서 빠질 때 언마운트만). 밖에서 만들어 넘긴 Instance를 목록에 태울 때 씁니다 — 단 그 Instance는 먼저Claim으로 quad 소유로 넘겨져 있어야 합니다(claim되지 않은 원소는 Slot이 거부합니다).OwnsElements = false는 “버릴 때 파괴하지 않는다”는 뜻이지 소유 검사를 건너뛰는 옵션이 아닙니다. 그리고 목록이 마운트된 채로 그 화면(부모 Instance)이 파괴되면 엔진이 자손을 지우므로 넘긴 Instance도 같이 죽습니다 — 살려야 하면 철거 전에 data에서 빼세요.
data는 평범한 배열이거나 배열을 담은 State입니다. State면 값이 바뀔 때마다 재조정(reconcile)이 돕니다.
3. 순서와 위치는 호출자가 정한다
섹션 제목: “3. 순서와 위치는 호출자가 정한다”Slot은 LayoutOrder라는 이름을 알지 못합니다. 자동으로 넣어주는 안은
검토 후 기각됐습니다 — 컴포넌트가 스스로 지정한 LayoutOrder를 Slot이 조용히
덮어쓰게 되고(“매직 없이 명시적”이라는 기조와 충돌), LayoutOrder는 Roblox 전용
프로퍼티라 엔진을 모르는 quad-base의 Slot 층에 그 지식이 새는 레이어링 위반이기 때문입니다.
UIListLayout으로 세로 배치를 할 때의 정본 관용구는 이렇습니다.
local function updateFn(ctx: { Item: any, Index: number, Offset: q.State<number>, -- 이 Slot 앞에 놓인 물리 자식 수를 담은 읽기 전용 State Prev: any, UserData: any,}): (any, any) if ctx.Item == q.KeyGone then return nil, ctx.UserData -- 사라진 키 — 파괴 end
if not ctx.Prev then -- 새 요소: 이전 Source를 재사용하지 말고 처음부터 올바른 값으로 만든다 local layoutOrder = q.Source(ctx.Index) local order: q.State<number> = layoutOrder:Depend(ctx.Offset):Compute(function(i) return i:Get() + ctx.Offset:Get() end) return q.Elem "Frame" { LayoutOrder = order, -- ... }, { layoutOrder = layoutOrder } end
-- 재활용: 실제로 바뀔 때만 Set local layoutOrder = ctx.UserData.layoutOrder if layoutOrder:Get() ~= ctx.Index then layoutOrder:Set(ctx.Index) end return ctx.Prev, ctx.UserDataend⚠️
:Depend(ctx.Offset)으로 모은 값은Compute콜백에 포지셔널로 넘어오지 않습니다. 두 번째 자리에 오는 것은 직전 계산 결과(previous)입니다 — 위처럼 클로저로ctx.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(ctx: any): (any, any) local entry, prev, ud = ctx.Item, ctx.Prev, ctx.UserData 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: q.State<UDim2> = abs:Compute(function(a) return UDim2.new(0, 0, 0, (a:Get() - 1) * ROW) end) local row = q.Elem "Frame" { Size = UDim2.new(1, 0, 0, ROW), Position = position, BackgroundTransparency = 1,
q.Elem "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 q.Elem "ScrollingFrame" { Size = UDim2.fromScale(1, 1), CanvasSize = UDim2.new(0, 0, 0, #items * ROW), ScrollBarThickness = 6,
q.OnChange "ScrollingFrame" "CanvasPosition" (function(pos: Vector2) scrollY:Set(pos.Y) end), q.OnChange "ScrollingFrame" "AbsoluteWindowSize" (function(size: Vector2) viewportHeight:Set(size.Y) end),
rows, }endq.OnChange "Class" "Name" (fn)는 숫자 키 자리에 놓는 디스크립터입니다(문자 키가
아닙니다). 콜백은 엔진 인자만 받습니다.
5. 스크롤 이벤트 폭주 누그러뜨리기 — Throttle
섹션 제목: “5. 스크롤 이벤트 폭주 누그러뜨리기 — Throttle”CanvasPosition은 한 프레임에도 여러 번 바뀔 수 있고, 바뀔 때마다 §4의 window
계산과 Slot:List 재조정이 돕니다. 목록을 그렇게 자주 다시 자를 필요는 없으니,
원천에 q.Throttle을 :Apply로 붙여 정해진 창마다 한 번만 아래로 통지하게
하고, window는 그 게이트된 State를 보게 합니다.
-- §4의 VirtualList 안에서 바뀌는 두 곳local scrollY = q.Source(0) -- OnChange 콜백은 그대로 이걸 :Set 한다local gatedScrollY: q.State<number> = scrollY:Apply(q.Throttle { Time = 0.1 }) -- 0.1초 창마다 한 번 통지 — 결과 타입은 호출부가 명시(레퍼런스 sugar/03)
local window = gatedScrollY:Compute(function(y, _previous, height) -- …§4 본문 그대로…end, viewportHeight)- 창 안에서 막히는 것은 통지이지 값이 아닙니다 —
gatedScrollY:Get()은 언제나 마지막 스크롤 위치입니다. - 스크롤이 멈춘 뒤에 한 번만 계산하고 싶다면
q.Debounce를 씁니다. 옵션과Flush/Cancel은 레퍼런스: Debounce / Throttle에 있습니다. - 시간이 아니라 코드 구간을 직접 열고 닫고 싶다면(데이터 여러 개를 한꺼번에
바꾸는 동안 목록을 멈추는 경우)
Blocker가 그 도구입니다 — 시작하기 18.
6. 확인 방법과 이 레시피가 안 해주는 것
섹션 제목: “6. 확인 방법과 이 레시피가 안 해주는 것”확인 — 개발자 콘솔(F9) → Memory의 Instances 수가 목록 길이가 아니라
윈도우 크기에 비례해 머무는지, 그리고 updateFn의 “새로 만드는” 갈래에
카운터를 넣어 살아남은 키가 다시 만들어지지 않는지 봅니다.
안 해주는 것
- 행 높이 측정이 없습니다. 위 예제는 모든 행이
ROW로 같은 높이라고 가정합니다 — 가변 높이는 직접 누적 높이를 관리해야 합니다. - 윈도우 밖으로 나간 행은 재활용되지 않습니다. 키가 사라지면 파괴(또는
q.Detach로 홀드)이고, 재활용은 “화면에 계속 남아 있는 키”에만 적용됩니다.
부록: 왜 재정렬이 no-op인가
섹션 제목: “부록: 왜 재정렬이 no-op인가”Roblox에서는 형제의 물리적 순서가 화면 순서를 정하지 않습니다 — 순서는 UIListLayout의 SortOrder/LayoutOrder나 ZIndex가 정합니다. 그래서 quad-roblox 백엔드의 재정렬 op(nativeMove/nativeSwap)는 의도된 no-op이고, slot:Move/slot:Swap으로 바뀌는 것은 quad의 부기(이 Slot 안의 인덱스, slot:Get/slot:IndexOf, 이후 삽입 위치)뿐입니다. 그래서 이 레시피가 순서를 LayoutOrder 바인딩으로 정합니다(§3). 계약 전체는 레퍼런스: Slot의 :Move/:Swap 절을 보세요.
이해 점검
섹션 제목: “이해 점검”무엇이 재활용을 결정하나
긴 목록에서 살아남은 항목이 다시 만들어지지 않게 하는 것은 무엇인가요?
-
keyFn이 정하는 항목의 신원입니다 — 생략하면 배열 인덱스가 키가 되어, 목록이 밀릴 때마다 신원이 어긋나 전부 새로 만들어집니다 -
keyFn을 생략해도 quad가 항목 테이블의 신원으로 같은 항목을 알아봅니다 -
keyFn은 항목의 정렬 순서를 정하는 함수라 재활용과는 무관합니다
신원이 안정적이어야 ctx.Prev가 넘어오고, ctx.Prev를 그대로 돌려주는 갈래가 마운트도 파괴도 없는 가장 싼 경로입니다.
윈도잉에서 위치를 잡는 법
보이는 구간만 그리는 윈도우 방식에서 행의 위치와 스크롤 범위는 어떻게 잡나요?
- 항목의 절대 인덱스로
Position을 직접 계산하고, 스크롤 범위는CanvasSize로 고정합니다 - 전체 목록과 똑같이
LayoutOrder로 순서만 정하고 배치는UIListLayout에 맡깁니다 - 행 높이는 quad가 측정해 주므로 가변 높이 행도 그대로 둘 수 있습니다
목록의 일부만 자식으로 존재하니 레이아웃이 전체 스크롤 높이를 만들어낼 수 없어서, 두 방식 중 하나를 골라야 합니다. 그리고 quad는 행 높이를 측정해 주지 않으므로, 절대 인덱스로 위치를 계산하는 방식은 모든 행이 같은 높이라고 가정합니다.
퀴즈 진행 상황
0 / 0 답변한 문제 (0%)
0 정답
퀴즈 완료!
0%
0 / 0 정답
답변함: 0 / 0 (0%)