콘텐츠로 이동

10. 스타일을 값으로 들고 다니기 — Modifier

대상 독자: 09. 생성만 보고 싶다면을 끝낸 개발자 목표: 반복되는 프로퍼티 묶음을 값 하나로 빼서 여러 곳에 놓기

카드와 버튼의 색·테두리를 다른 화면에서도 그대로 쓰고 싶습니다. Modifier는 “이 프로퍼티들을 이렇게 설정한다”를 값 하나로 만든 것입니다. 컴포넌트와는 무관합니다 — 지금처럼 인스턴스(카드 Frame, 버튼) 하나하나에 그대로 놓습니다.


-- … 위쪽 코드에 이어집니다(card 위에 한 덩이를 둡니다)
const CardStyle = q.Modifier "Frame" {
BackgroundColor3 = Color3.fromRGB(35, 35, 42),
BorderSizePixel = 0,
}

card의 문자 키에서 BackgroundColor3 한 줄을 지우고, 숫자 키 맨 앞에 CardStyle을 놓습니다.

-- … 위쪽 코드에 이어집니다
const card = q.Elem "Frame" {
CardStyle, -- ← 숫자 키
Size = UDim2.fromOffset(240, 160),
UICorner = 12,
-- …05·07~09장에서 붙인 숫자 키와 자식들 생략…
}

실행하면 겉모습은 그대로입니다(BorderSizePixel = 0이 새로 들어가 검은 1px 테두리가 빠지는 것 말고는) — 다만 이제 그 스타일은 값이라, 다른 인스턴스에도 같은 것을 놓을 수 있습니다.

q.Modifier "Class"는 클래스 태그가 붙은 생성자입니다. TextButton 전용 Modifier를 Frame의 숫자 키 자리에 넣으면 타입 검사에서 걸립니다. 테이블을 넘기는 위 형태와 빌더 체인 형태(q.Modifier "TextButton" ():TextSize(16)) 둘 다 됩니다. 필드 이름·값 타입까지 검사받는 건 체인 형태뿐입니다(테이블 형태는 클래스 태그만 붙고 필드는 런타임에서 잡힙니다).


Modifier의 필드 값 자리에는 리터럴뿐 아니라 State/Source도 올 수 있습니다. 그러면 그 Modifier를 놓은 인스턴스의 프로퍼티가 그 값을 따라갑니다.

-- … 위쪽 코드에 이어집니다(CardStyle을 이렇게 고칩니다)
const hot = q.Source(false)
const cardColor: q.State<Color3> = hot:Compute(function(h)
return if h:Get()
then Color3.fromRGB(240, 60, 60)
else Color3.fromRGB(35, 35, 42)
end)
const CardStyle = q.Modifier "Frame" {
BackgroundColor3 = cardColor, -- Compute를 테이블 안에 바로 쓰면 strict가 콜백 타입을 못 잡습니다 — 지역 변수로 빼세요
BorderSizePixel = 0,
}
const card = q.Elem "Frame" { CardStyle, Size = UDim2.fromOffset(240, 160), UICorner = 12 } -- …05·07~09장에서 붙인 숫자 키와 자식들은 그대로…
hot:Set(true) -- 카드 배경이 빨강으로 바뀐다(확인이 끝나면 이 줄은 지웁니다)

실행하면 hot:Set(true) 시점에 카드 배경이 바뀝니다. 03장에서 프로퍼티에 직접 꽂았던 그 흐름이, Modifier를 한 단계 거쳐 같은 자리에 도착한 것입니다.

우선순위는 이 장에서 하나만 알면 충분합니다. 직접 적은 문자 키가 Modifier보다 우선합니다.

그리고 하나 더 알아 두면 좋은 성질이 있습니다. Modifier는 불변입니다. setter를 하나 부를 때마다 새 값이 생기고 원본은 그대로라, 하나의 기본 스타일에서 갈라 나온 형제들이 서로를 오염시키지 않습니다.

-- … 위쪽 코드에 이어집니다
const base = q.Modifier "TextButton" { TextSize = 16 }
const big = base:TextSize(24) -- base는 그대로 16
Modifier가 여럿이면 누가 이기나요?

무엇이 무엇을 덮는지는 규칙 셋뿐입니다.

  1. 직접 적은 문자 키가 이깁니다. q.Elem "TextButton" { SomeMod, Text = "확정" }에서 Text는 어떤 Modifier가 무엇을 갖고 오든 그대로 유지됩니다.
  2. 숫자 키 자리에서는 뒤에 온 Modifier가 앞의 것을 필드 단위로 덮습니다.
  3. Modifier.Overridden(A, B)도 같은 방향입니다 — 뒤 인자가 앞 인자를 덮습니다. 닷 형태와 콜론 형태(a:Overridden(b)) 둘 다 됩니다.

빈 자리를 명시적으로 비우고 싶으면 q.None을 넣습니다. nil은 “이 Modifier는 그 필드에 관심 없음”이고, q.None은 “그 필드를 비워라”입니다 — 뜻이 다릅니다.

컴포넌트가 바깥에서 Modifier를 받아 자기 안쪽에 꽂을 때의 경계 규약(props.Modifier or q.None)과 상위 클래스 Modifier를 받는 법은 01. 컴포넌트 경계 규약과 스타일 합성 §2·§3에 있습니다.

Modifier는 프로퍼티만 담습니다. 이벤트 핸들러·Ref·자식까지 한 묶음으로 넘기고 싶다면 요소 테이블 조각인 q.Spread "Class" { … }가 있습니다 — 같은 키에서는 Modifier와 반대로 먼저 놓인 쪽이 이깁니다. 그것도 같은 문서의 §3에 있습니다.


3. 팩토리로 뽑고, 모듈 하나에 모으기

섹션 제목: “3. 팩토리로 뽑고, 모듈 하나에 모으기”

스타일을 함수로 만들면 인자를 받는 스타일이 됩니다.

-- 새 예시: 별도 스크립트
local function accent(color)
return q.Modifier "TextButton" {
BackgroundColor3 = color,
TextColor3 = Color3.fromRGB(255, 255, 255),
BorderSizePixel = 0,
}
end
const button = q.Elem "TextButton" { accent(Color3.fromRGB(0, 162, 255)), Text = "+ 1" }

이 버튼을 화면에 붙이면 넘긴 색으로 칠해집니다 — 흰 글자와 BorderSizePixel = 0은 팩토리 안에 그대로 있어서, 색만 다른 버튼을 얼마든지 찍어낼 수 있습니다.

권장하는 배치는 스타일을 모듈 하나에 모아 내보내는 것입니다. 화면마다 색 리터럴이 흩어지지 않고, 나중에 테마를 바꿀 때 고칠 자리가 한 곳이 됩니다.

-- 새 파일: ReplicatedStorage/Client/UI/Styles
const q = require("@game/ReplicatedStorage/Client/UI/Quad")
local function accent(color)
return q.Modifier "TextButton" {
BackgroundColor3 = color,
TextColor3 = Color3.fromRGB(255, 255, 255),
BorderSizePixel = 0,
}
end
return {
Card = q.Modifier "Frame" {
BackgroundColor3 = Color3.fromRGB(35, 35, 42),
BorderSizePixel = 0,
},
Accent = accent,
}

쓰는 쪽은 const Styles = require("@game/ReplicatedStorage/Client/UI/Styles") 뒤에 Styles.Card, Styles.Accent(색)을 숫자 키 자리에 놓습니다 — 모양은 §1의 CardStyle을 그런 모듈로 옮긴 것입니다(§2의 hot 파이프가 든 CardStyle을 이것으로 갈아 끼우면 배경은 정적 색으로 돌아갑니다). 이 튜토리얼에서는 만들어만 두고 쓰지 않습니다 — 실전 배치는 05. 디자인 토큰과 테마 전환에 있습니다.


4. 테마를 바꿀 때 — Modifier를 갈아 끼우지 말고 값을 흘립니다

섹션 제목: “4. 테마를 바꿀 때 — Modifier를 갈아 끼우지 말고 값을 흘립니다”

§2와 §3을 합치면 “테마 전환”의 모양이 나옵니다. 팩토리에 리터럴 대신 State를 넘기면, 그 팩토리가 만든 Modifier는 그 State를 따라갑니다.

-- … 위쪽 코드에 이어집니다(§3 Styles 모듈의 accent 함수를 이 스크립트에도 같은 모양으로 정의해 둔 것으로 칩니다 — 모듈로 만들었다면 Styles.Accent)
const accentColor = q.Source(Color3.fromRGB(0, 162, 255))
const button = q.Elem "TextButton" { accent(accentColor), Text = "+ 1" }
accentColor:Set(Color3.fromRGB(240, 60, 60)) -- 버튼 색만 바뀐다(확인이 끝나면 이 줄은 지웁니다)

흔한 시도 하나는 안 됩니다. 테마마다 Modifier를 통째로 만들어 두고 q.Source(darkStyle)처럼 Modifier 자체를 State에 담아 갈아 끼우려 하면 Quad0181 Source: cannot hold a Modifier as a Source value로 막힙니다. 바뀌는 것은 스타일의 필드이므로, 필드마다 State를 두고 Modifier가 그것을 참조하게 합니다 — 위 모양 그대로입니다. 왜 그렇게 막아 두었는지는 10. 에러 읽는 법 함정 5에, 토큰 여러 개를 테마 하나로 묶는 실전 배치는 05. 디자인 토큰과 테마 전환에 있습니다.

한 걸음 더 가면 Modifier 위에 얹는 조합이 있습니다. mod:Apply(fn)은 fn(mod)를 돌려주는 호출 슈거라, 인자를 받아 함수를 돌려주는 팩토리와 짝지으면 “스타일 조각”을 체인 중간에 끼울 수 있습니다.

-- 새 예시: 별도 스크립트
local function emphasis(size)
return function(mod)
return mod:TextSize(size):TextColor3(Color3.fromRGB(255, 230, 120))
end
end
const title = q.Modifier "TextLabel" { BackgroundTransparency = 1 }:Apply(emphasis(24))
const label = q.Elem "TextLabel" { title, Text = "카운터" }

emphasis(24)가 돌려준 것은 값이 아니라 함수이고, 그 함수는 24를 기억합니다. :Apply는 넘긴 것이 함수인지만 검사하는 순수한 호출이라 emphasis(24)(base)와 같지만, 체인 안에서 읽히는 순서가 코드 순서와 같아집니다. 이 “함수를 돌려주는 함수” 모양에는 13장에서 이름(커링)을 붙입니다.


누가 누구를 덮나

q.Elem "TextButton" { SomeMod, Text = "확정" }에서 SomeMod도 Text를 갖고 있다면 어느 값이 남나요?

  • 직접 적은 문자 키가 이기므로 "확정"이 그대로 남습니다
  • 숫자 키가 먼저 처리되므로 Modifier가 갖고 온 값이 남습니다
  • 한 프로퍼티를 둘이 요구한 것이라 그 자리에서 에러가 납니다

우선순위 규칙 셋 중 첫째가 “직접 적은 문자 키가 이긴다”입니다. 숫자 키 자리에 Modifier가 여럿일 때 그들끼리는 뒤에 온 것이 앞의 것을 필드 단위로 덮고, Modifier.Overridden(A, B)도 같은 방향입니다.

`Modifier`는 불변입니다

const big = base:TextSize(24)를 부른 뒤 base는 어떻게 되나요?

  • base의 값도 24로 바뀌고, big은 같은 것을 가리키는 다른 이름입니다
  • setter가 새 값을 만들어 돌려주므로 base는 그대로 16입니다
  • base는 갈라져 나간 뒤로 더 쓸 수 없어 big만 자리에 놓을 수 있습니다

Modifier는 불변이라 setter를 하나 부를 때마다 새 값이 생기고 원본은 그대로입니다. 그래서 하나의 기본 스타일에서 갈라 나온 형제들이 서로를 오염시키지 않습니다.


퀴즈 진행 상황

0 / 0 답변한 문제 (0%)

0 정답