서페이스 스타일 | CoUI
본문으로 건너뛰기
LogoCoUI

서페이스 스타일

표면 재질 축 — 켜는 법, 무엇이 달라지는지, 어디까지 닿는지

목차

앱 전체의 표면이 어떤 재질로 보이는지를 한 번에 정하는 축입니다. 컴포넌트를 하나도 고치지 않고 켭니다.

다섯 가지가 있습니다 — Default · Neo-brutalism · Liquid Glass · Neumorphism · Claymorphism. 이 문서 상단의 스위처로 지금 바로 바꿔 보실 수 있습니다.

켜는 법#

읽는 쪽은 양 플랫폼이 같습니다 — SurfaceStyleScope.of(context). 정하는 쪽이 다릅니다.

// Flutter — 앱 루트가 필드로 받습니다
CoUIApp(
  surfaceStyle: CoreSurfaceStyleId.liquidGlass,
  theme: appTheme,
  home: const HomePage(),
)
// Web — 앱 루트는 CoUIWeb 이고, 스타일은 스코프의 정적 메서드로 정합니다
SurfaceStyleScope.setSurfaceStyle(CoreSurfaceStyleId.liquidGlass);

CoUIWeb(
  theme: ThemeData.coui,
  child: MyHomePage(),
)

Web 은 시트에 스타일 블록이 들어 있어야 합니다. 규칙이 참조할 변수가 없으면 아무것도 그려지지 않습니다:

final config = ThemeConfig.defaults();
// <style> 안에 config.surfaceStyleVariableBlocks 를 함께 넣습니다.

CoUIWeb 에는 CoUIApp 의 surfaceStyle 에 해당하는 생성자 필드가 없습니다. 이 문서는 그 사실을 기록만 하고 정당화하지 않습니다 — 한쪽에만 있는 파라미터는 이 킷의 기준으로 미구현 신호이고, 그 판정은 별도로 추적합니다.

data-coui-style 을 직접 쓰지 마세요#

Web 에서 축은 문서 속성으로 전달되지만, 그 속성을 손으로 쓰는 것은 공개 API 를 우회하는 저수준 경로입니다. setSurfaceStyle 은 기본 스타일일 때 값을 쓰는 대신 속성을 지웁니다 — 기본 규칙이 이미 그 상태를 기술하므로, 속성을 남기면 "스타일 없음" 이 두 가지 표기를 갖게 됩니다.

속성을 직접 쓰는 자리는 이 사이트에 딱 하나 있습니다. 첫 페인트 전에 저장된 선택을 복원하는 인라인 스크립트이고, 그건 사이트 인프라이지 소비 앱이 하는 배선이 아닙니다.

헤더의 스위처는 이 사이트의 데모입니다#

매 페이지 상단에 있는 스타일 스위처는 문서 사이트가 자기 축을 보여주려고 만든 것이고, 킷은 최종 사용자용 스타일 피커를 출하하지 않습니다.

런타임 전환이 불가능하다는 뜻은 아닙니다 — setSurfaceStyle 이 바로 그 런타임 API 입니다. 앱이 사용자에게 스타일 선택을 노출하고 싶다면 그 UI 는 앱이 만들고, 이 축이 그것을 받습니다.

무엇이 달라지는가#

축은 자기를 표면이라고 표시한 곳에만 닿습니다. "전부 바뀐다" 는 참이 아니고, 참이 아닌 문장은 안 바뀐 컴포넌트를 발견했을 때 버그로 읽힙니다.

어느 컴포넌트가 표면인지는 목록이 아니라 규칙이 답합니다. 두 질문이고 둘 다 참이어야 합니다.

  1. 자기 채움을 그리는가 — 소유한 배경이 있는가. 채움이 없으면 반투명하게 만들 것이 없습니다.
  2. 뒤로 무언가 지나갈 수 있는가 — 콘텐츠 위에 떠 있거나 콘텐츠가 그 아래로 스크롤되는가. 지나갈 것이 없으면 블러가 샘플할 것이 없습니다.

그래서 떠 있는 패널(팝업 · 다이얼로그 · 툴팁 · 토스트 · 드로어 · 드래그 패널)과 콘텐츠 위에 도킹된 바(내비게이션 바 · 독 · 스캐폴드의 앱 바만 — 페이지 배경은 가장 바깥 채움이라 뒤에 아무것도 없고, 푸터는 콘텐츠 위에 뜨지 않습니다)가 재질을 받고, 그 표면을 합성하는 컴포넌트(Card 는 OutlinedContainer 를, HoverCard 는 Card 를)는 그것을 통해 이미 받습니다. 버튼도 두 질문에 답합니다 — 자기 상자를 칠하고, 무엇 위에든 놓입니다(카드, 사진, 스크롤되는 본문). 카드 같은 표면 위에 놓인 버튼은 중첩이 받습니다: 안쪽 재질은 블러를 내려놓고 가장자리만 남겨, 유리 위의 유리가 두 번 흐려지지 않습니다. 쉬는 상태에서 채움이 없는 변형(ghost · link · plain · menu 류)은 첫 질문에서 떨어집니다 — 상자가 없으니 흐릴 뒤도, 가장자리를 그릴 둘레도 없습니다.

목록을 여기 적지 않는 이유는 목록이 새 컴포넌트가 추가될 때 조용히 낡기 때문입니다. 지금 이 규칙이 어느 컴포넌트에 무엇이라 답하는지는 레포의 scripts/audit/surface_eligibility_triage.sh --markdown 이 매번 새로 냅니다.

나머지 컴포넌트는 이 표면들 안에 놓이거나, 다시 칠해진 시맨틱 토큰을 상속받아 따라옵니다.

Liquid Glass 가 실제로 하는 일#

다섯 겹입니다. 앞의 넷은 어디서나 그려지고, 마지막 하나는 엔진이 허락하는 곳에서만 그려집니다.

겹무엇
반투명 채움표면이 뒤를 비칩니다
배경 블러 + 채도 + 휘도비쳐 보이는 것을 흐리고 색을 살짝 올리고, 모드에 따라 살짝 어둡히거나 밝힙니다
엣지 시인가장자리를 훑는 얇은 띠와 광원 각도를 따르는 안쪽 베벨. 가로 변과 세로 변의 무게가 다릅니다
윤곽 림 같은 광원이 벽의 능선에 맺힌 반사 — 윤곽 전체를 도는 얇은 밝은 선. 윤곽의 법선이 광원을 향하는 곳에서 가장 밝아, 알약 모양에서는 좌상단 호가 빛나고 각진 모서리에서는 조용합니다
굴절 가장자리 띠 안에서 배경이 휘어 보이고, 면 전체는 얕은 돔처럼 뒤를 살짝 확대합니다(렌즈). 색 채널은 띠에서만 조금씩 다르게 휘어 경계에 프리즘 띠가 생기고, 렌즈는 한 장입니다

여기에 없는 두 겹 — 서리와 몸체 음영 — 은 앱이 켜는 값입니다. 프리셋이 말하지 않으므로 쓰지 않은 앱의 화면은 바뀌지 않습니다. 아래 "서리와 몸체 음영" 절에 있습니다.

카드도 팝오버도 같은 유리입니다. 이 프리셋은 두 역할(콘텐츠 · 떠 있는 것)에 같은 재질을 진술합니다. 역할은 표면이 어디에 놓이는가와 중첩 테이블이 무엇을 주는가를 가르지, 무엇으로 만들어졌는가를 가르지 않습니다 — 유리 독 옆의 카드가 불투명한 판이면 그건 재질이 아니라 바 하나의 장식으로 읽힙니다.

독의 활성 알약은 재질이 아니라 컴포넌트의 것입니다. Dock 은 어느 스타일에서든 활성 항목 뒤에 알약을 그리고, 항목이 바뀌면 떠나는 알약과 도착하는 알약을 모프 엔진에 두 몸체로 넘겨 한 덩어리로 흘러가게 합니다 — 팝오버가 트리거에서 나오는 것과 같은 엔진, 같은 윤곽. 알약은 표면 위에 얹히는 것이라 유리로 만들지 않습니다(위 규칙). 채움·모서리·오버행·템포는 CoreDockStyle 의 indicator* 필드입니다.

림은 크기를 따라 다시 계산됩니다. 띠와 베벨이 크기 무관한 값으로 진술되는 것과 달리, 림의 밝기 분포는 윤곽의 법선을 따르므로 표면의 사각형과 반경이 있어야 정해집니다. Core 가 그 윤곽을 한 번 샘플해 스윕 정지점을 내고, Flutter 는 스윕 그라디언트로, Web 은 요소마다 conic-gradient 로 같은 정지점을 그립니다 — 어느 쪽도 정점이 어느 호에 놓일지 스스로 정하지 않습니다.

반투명과 블러는 짝입니다. 불투명한 채움 뒤의 블러는 보이지 않고, 블러 없는 반투명은 그냥 옅은 표면입니다. 둘은 항상 함께 움직입니다.

블러는 뒤에 무언가 있을 때만 보입니다. 평평한 단색 위에 놓인 유리 표면은 흐릴 것이 없어 유리처럼 보이지 않습니다 — 결함이 아니라 재질의 성질입니다.

유리 위의 유리는 블러를 다시 걸지 않습니다. 중첩된 backdrop 패스는 쌓여서 안쪽이 과하게 탁해지므로, 표면 안의 표면은 채움과 띠만 받고 블러 패스는 바깥 것 하나를 씁니다 — Apple 이 유리 위 컨트롤에 얇은 재질을 쓰라는 것과 같은 결론입니다.

앱이 정할 수 있는 것#

프리셋은 기본 답이지 유일한 답이 아닙니다. CoreSurfaceTuning 으로 값을 진술하면 되고, 말하지 않은 값은 프리셋 것이 그대로 남습니다.

// Flutter — 활성 스타일이 한 번에 하나라 단수
CoUIApp(
  surfaceStyle: CoreSurfaceStyleId.liquidGlass,
  surfaceTuning: const CoreSurfaceTuning(
    surfaceOpacity: 0.5,
    surfaceBlur: 32,
    surfaceSaturation: 1.6,
  ),
  theme: appTheme,
  home: const HomePage(),
)
// Web — 시트가 모든 스타일의 블록을 한 벌에 굽기 때문에 맵
ThemeConfig(
  palette,
  surfaceTunings: const {
    CoreSurfaceStyleId.liquidGlass: CoreSurfaceTuning(
      surfaceOpacity: 0.5,
      surfaceBlur: 32,
      surfaceSaturation: 1.6,
    ),
  },
)

단수와 복수의 차이는 두 transport 의 성질입니다 — Flutter 는 활성 테마 하나를 만들고, Web 은 스타일시트 한 벌에 모든 스타일의 블록을 함께 굽습니다.

필드무엇
surfaceOpacity표면 채움의 알파
surfaceBlur배경 블러 반경
surfaceSaturation배경 채도 배율 (두 모드 모두)
surfaceSaturationInDarkMode다크 모드에서만 다른 배경 채도 배율
sheenWidth엣지 띠 두께
sheenFade코너에서 풀 강도까지의 변 길이 비율
rimWidth윤곽 림의 두께. 0 이면 림을 끕니다
rimIntensity윤곽 림이 광원을 정면으로 향한 곳에서의 밝기 (0–1)
lightAngle 엣지 빛이 오는 방향 — 위쪽에서 시계 방향으로 잰 도(°). 벽과 림이 같은 빛을 받습니다 (두 모드 모두)
lightAngleInDarkMode다크 모드에서만 다른 빛의 방향
refractionModel 굴절 띠가 빛을 휘는 법칙 — snell(기본) · paraxial
surfaceFrostBlur 서리 구름의 블러 반경(σ). surfaceFrostOpacity 와 함께 적습니다
surfaceFrostOpacity구름이 표면 자신의 블러 위에 깔리는 몫 (0 초과 1 이하, 두 모드 모두)
surfaceFrostOpacityInDarkMode다크 모드에서만 다른 구름의 몫
surfaceBodyShade몸체 음영의 세기 — 흰색이 잃는 몫 (0–1, 두 모드 모두)
surfaceBodyShadeInDarkMode 다크 모드에서만 다른 음영 세기. 라이트는 말하지 않은 채 다크만 적을 수 있습니다

림 두 값은 카드에도 팝오버에도 같이 닿습니다 — 재질의 값이라 표면 하나만 고르는 손잡이는 없습니다. 카드의 림만 다르게 보이면 그건 재질이 아니라 그 카드의 기하(반경 · 크기)이고, 림은 그 기하를 따라 다시 계산됩니다.

빛의 방향도 같습니다. 한 장면을 두 방향의 빛으로 비추면 표면이 서로 다른 해 아래 있는 것처럼 읽히므로, 카드 안의 버튼처럼 중첩된 표면의 벽도 같은 방향을 받습니다.

모드마다 다르게 정하기#

배경 채도와 빛의 방향은 라이트와 다크를 따로 정할 수 있습니다. 어두운 배경은 같은 배율에서도 밋밋해 보일 수 있고, 어두운 장면의 빛은 다른 방향이 어울릴 수 있기 때문입니다.

// 숫자는 설명을 위한 예시이고 디자이너 판단 값입니다 — 프리셋이 정하는 값이 아닙니다.
const CoreSurfaceTuning(
  surfaceSaturation: 1.3,            // 라이트
  surfaceSaturationInDarkMode: 1.9,  // 다크
  lightAngle: -30,
  lightAngleInDarkMode: -60,
)

이름에 InDarkMode 가 없는 값은 두 모드에 대한 진술이고, 프리셋이 다크에 따로 적어 둔 값이 있으면 그것을 대체합니다. InDarkMode 만 적으면 라이트는 프리셋이 정한 그대로 남습니다. 둘 다 적으면 라이트는 앞의 것, 다크는 뒤의 것입니다. 라이트만 바꾸고 다크는 프리셋 값을 지키려면 다크 값을 다시 적습니다 — 프리셋의 값은 레시피에 공개되어 있습니다.

값을 한 쌍의 객체가 아니라 숫자 둘로 받는 이유는 범위 검사가 컴파일 시점에 일어나게 하려는 것입니다(채도는 1~2, 방향은 한 바퀴 안). 범위 밖의 값은 const 호출부에서 빌드되지 않습니다.

어떻게 닿는가.

  • Flutter 는 테마를 만들 때 그 모드의 값으로 접어서 내려보냅니다. 표면은 모드를 묻지 않고, 모드가 바뀌면 새 테마가 내려옵니다.
  • Web 은 라이트 값을 기본 규칙에 쓰고, 다크 값은 다크 팔레트에 닿는 모든 형태(data-theme 속성, 다크 스코프, OS 설정)에서 다시 진술합니다. 테마 전환은 문서 속성 하나를 쓸 뿐 트리를 다시 만들지 않으므로, 모드 차이는 브라우저가 다시 매칭하는 CSS 여야 하기 때문입니다. 채도는 휘도와 같은 필터 목록에 실려, 다크 규칙은 목록 전체를 다시 쓰고 둘 중 하나만 따로 바꾸지 않습니다.
  • 빛의 방향과 굴절의 법칙은 CSS 밖에서도 읽힙니다. 림의 스윕과 굴절의 변위 맵은 요소를 잰 클라이언트 런타임이 굽는데, 런타임은 앱의 튜닝(빌드 시점의 결정)을 볼 수 없습니다. 그래서 시트가 프리셋과 다른 값만 그 표면의 사용자 정의 속성으로 진술하고 런타임이 그것을 읽습니다. 튜닝하지 않은 앱의 시트는 이 속성을 하나도 싣지 않습니다. 다크가 다른 방향이면 런타임은 모드가 바뀔 때 다시 잴 수 없으므로 스윕을 두 벌 쓰고, 시트의 다크 규칙이 고릅니다.
  • 이 속성은 상속되므로, 튜닝한 값이 있는 시트는 표시된 모든 표면에서 특이도 0 으로 초기화합니다. 다른 스타일의 미리보기를 튜닝한 표면 안에 놓아도 바깥 표면의 빛을 물려받지 않게 하려는 것입니다.
  • 팔레트가 바뀌면 런타임이 붙인 필터를 새 팔레트의 목록에 다시 붙입니다. 런타임은 붙을 때의 팔레트가 계산한 필터 목록 뒤에 자기 필터를 이어 인라인으로 쓰는데, 인라인은 다른 팔레트의 규칙보다 앞서므로 다시 붙이지 않으면 전환 뒤에도 이전 팔레트의 휘도와 채도가 남습니다.

서리와 몸체 음영#

블러 위에 얹는 두 값입니다. 둘 다 앱이 켜는 값이고, 말하지 않으면 그려지지 않습니다.

서리 (surfaceFrost*)몸체 음영 (surfaceBodyShade*)
무엇 뒤 배경을 훨씬 넓게 흐린 구름을 표면 자신의 블러 아래에 깝니다 밝은 배경을 그 픽셀 자신의 휘도만큼 어둡힙니다
식 몸체 = 재질( 몫 · 구름 + (1 − 몫) · 배경 ) 출력 = 입력 · (1 − 세기 · 휘도²)
모드마다 surfaceFrostOpacityInDarkMode surfaceBodyShadeInDarkMode
비용표면마다 backdrop 패스 하나이미 도는 패스 위의 필터 몇 개

서리는 블러를 키운 것이 아닙니다. 블러 하나로는 "구름 한 장 아래로 내용이 27% 비친다" 를 진술할 수 없습니다. 몫이 1 이면 구름이 배경을 대체하고 표면 자신의 블러는 보이지 않습니다. 그 아래에서는 약하게 흐린 사본이 1 − 몫 만큼 구름 위로 비쳐, 뒤의 글자가 흐린 글자가 아니라 옅은 유령으로 읽힙니다. 구름 σ 는 아무것도 알아볼 수 없을 만큼 넓게 잡습니다.

몸체 음영은 휘도 배율이 아닙니다. 표면의 배경 휘도(backdropBrightness)는 모든 픽셀에 같은 수를 곱해 검정도 하양도 같은 비율을 잃습니다. 음영은 픽셀의 휘도를 입력으로 받는 함수라서, 하양은 세기만큼 잃고 중간 회색(휘도 0.5)은 그 4분의 1, 검정은 아무것도 잃지 않습니다. 가중치는 채도 올리기가 쓰는 Rec. 709(0.213 · 0.715 · 0.072)와 같아서 이 킷에는 "얼마나 밝은가" 가 하나입니다. 제곱은 룩의 결정입니다 — 선형으로 두면 중간 톤이 페이지만큼 어두워지고, 제곱은 배경이 정말 밝아질 때까지 중간 톤을 건드리지 않습니다.

순서. 서리 → 블러 → 채도 → 휘도 → 음영, 그 뒤에 굴절입니다. 순서는 룩의 결정이고(디자이너 판단 값), 서리가 먼저인 이유는 구름이 원본 배경에서 나와야 하기 때문입니다 — 이미 흐려진 배경에서 구름을 만들면 표면의 블러가 구름의 모양을 정합니다. 음영은 픽셀 하나만 읽는 점 연산이라 굴절과 좌표를 주고받지 않습니다. Web 은 음영을 먼저 걸고 굴절이 그 뒤에 오며, Flutter 의 프로그램은 색 채널을 분산으로 따로 읽는 샘플마다 음영을 입힌 뒤 채널을 고르는 것으로 같은 순서를 지킵니다.

어떻게 그려지는가.

  • Web 은 둘 다 표면의 backdrop-filter 목록 안의 SVG 필터입니다. 필터 문서는 스타일시트가 data URI 로 직접 싣습니다 — 페이지에 보이지 않는 요소를 두면 서버 렌더에는 그 요소가 없고, 목록이 가리키는 곳이 없는 필터는 브라우저가 조용히 건너뛰어 항이 말없이 사라집니다. 문서는 스타일 한 개당 :root 에 한 번만 정의하고 규칙은 이름으로 가리킵니다. 중첩한 요소의 backdrop-filter 는 페이지를 보지 못하므로(측정) 구름을 별도 요소로 얹는 방법은 쓸 수 없습니다.
  • Flutter 는 서리를 재질의 backdrop 패스 아래에, 같은 클립 안의 형제 패스로 그립니다. 형제라서 위쪽 레이어가 pass 를 저장하는 자리의 제약(아래 "굴절은 요청이지 보장이 아닙니다")을 받지 않고, 셰이더가 없어 BackdropFilter 가 도는 엔진이면 어디서나 같은 구성입니다. 몸체 음영은 굴절을 그리는 프래그먼트 프로그램이 함께 그립니다 — 굴절하지 않는 표면은 굴절 입력을 0 으로 둔 같은 프로그램을 씁니다. 음영은 좌표를 재지 않으므로 굴절이 물러나는 자리에서도 그려집니다.

접근성. 둘 다 꾸밈이므로 사용자가 내용을 더 또렷하게 보려고 켠 설정이 이깁니다.

설정FlutterWeb
고대비 테마를 접을 때 두 값을 싣지 않습니다 prefers-contrast: more 규칙 하나가 두 사용자 정의 속성을 initial !important 로 비웁니다. 필터 목록의 빈 폴백이 그 항만 지우고 목록은 다시 쓰지 않습니다
강제 색상해당 신호 없음forced-colors: active 가 같은 규칙에 들어 있습니다
투명도 줄이기 테마를 접을 때 두 값을 싣지 않고, 패스 자체가 없습니다 기존 규칙이 backdrop-filter 전체를 지웁니다 — 두 항도 그 목록 안에 있습니다
동작 줄이기해당 없음 — 움직이지 않는 재질입니다해당 없음

Web 의 클라이언트 런타임은 굴절을 붙일 때 계산된 필터 목록을 복사해 인라인으로 쓰는데, 인라인은 시트의 규칙보다 앞섭니다. 그래서 설정이 바뀌면 런타임이 다시 붙입니다 — 그러지 않으면 시트가 지운 서리와 음영이 복사본에 남습니다.

중첩. 표면 안의 표면은 backdrop 패스를 그리지 않으므로(위 "유리 위의 유리") 서리도 음영도 없습니다.

비용. 서리는 유리 표면마다 패스 하나를 더 돌립니다. 화면에 있는 유리 표면의 수만큼 더하는 비용이고, 이 레포는 기기에서 그 비용을 재지 않았습니다. 그래서 켜지 않은 앱은 이 비용을 내지 않고, 켠 앱은 표면 수를 스스로 가늠해야 합니다. 서리는 이 재질에서 가장 먼저 포기할 항입니다 — 없어도 블러 · 채도 · 휘도 · 음영 · 굴절은 남습니다.

Web 에서 서리와 음영이 사라지는 자리. img-src 가 data: 를 막는 CSP 에서는 그 항이 빠지고 나머지 목록은 그대로 그려집니다 — Chromium 에서 측정했고, 두 항이 빠진 표면은 처음부터 말하지 않은 튜닝의 표면과 같은 픽셀을 그립니다.

Safari 는 다릅니다. WebKit 은 SVG 필터를 이름으로 든 backdrop-filter 목록을 받아들이고(CSS.supports 가 참) 그 목록을 통째로 그리지 않습니다 — 표면 자신의 블러 · 채도까지. 서리와 음영은 그 목록 안의 SVG 필터라서, 두 항을 그대로 두면 켠 앱의 유리는 Safari 에서 흐림 없이 색만 얹은 사각형이 됩니다. Safari 27.0(1배 화면)의 실제 창을 캡처해 쟀습니다 — blur(10px) saturate(2) 칸은 128×128 = 16 384 픽셀이 모두 바뀌지만, 서리 · 음영의 필터 문서를 이름으로 든 목록(서리 먼저 · 음영 마지막 · 둘 다 · 하나만)은 none 칸과 한 픽셀도 다르지 않았습니다. Firefox 는 같다고 기록했을 뿐 재지 않았습니다.

그래서 스타일시트가 그런 브라우저에서는 두 항을 목록에서 뺍니다. 두 사용자 정의 속성을 initial !important 로 비우는 규칙이고, 고대비 규칙과 같은 모양이라 목록의 var(--x,) 가 빈 폴백으로 답해 그 항만 사라지고 목록은 다시 쓰지 않습니다. 남는 것은 두 항 없이 목록이 그리던 유리 — 블러 · 채도 · 휘도 — 입니다. 두 항을 켜도 Safari 와 Firefox 에서 유리의 흐림은 지켜지고, 서리와 음영만 없습니다. 길은 둘입니다.

  • WebKit — 첫 페인트부터. 스타일시트의 기능 질의(@supports)가 Apple 자신의 기능(-apple-pay-button-style · font: -apple-system-body)을 물어, 스크립트도 속성도 없이 첫 스타일 계산에서 비웁니다. 표준 기능이나 접두 별칭을 묻지 않는 까닭은, Blink 가 그것을 들이는 날 서리와 음영을 그리는 브라우저에서 그 둘을 빼게 되기 때문입니다.
  • 그 밖의 브라우저 — 런타임이 알린 뒤. 굴절이 읽는 user agent 판정 하나로 클라이언트 런타임이 <html> 에 data-coui-svg-backdrop="off" 를 쓰고, 시트의 규칙이 그 속성을 읽습니다. 서버는 브라우저를 모르므로 쓰지 않습니다. 속성이 닿은 뒤에 만들어진 표면부터 흐립니다.

왜 WebKit 에 기능 질의가 따로 있는가. 목록에 SVG 필터가 든 채 처음 그려진 층은 목록이 나중에 고쳐져도 블러를 되찾지 않습니다. 속성만 쓰는 시트로 Safari 27.0 을 쟀을 때, 호스트를 설치하지 않은 페이지와 2.5 초 뒤에 설치한 페이지의 유리 칸은 계산된 목록이 깨끗한데도 none 칸과 128×128 중 127 픽셀만 달랐고, 문서가 파싱되자마자 설치한 페이지만 15 711 픽셀이 달랐습니다. 속성은 스크립트가 돈 뒤에야 닿으므로 서버가 구운 첫 페인트 뒤입니다. 기능 질의가 있는 시트는 세 페이지 모두 15 711 ~ 15 941 픽셀이 다르고, 호스트가 없는 페이지와 있는 페이지의 유리 안쪽(가장자리 10 픽셀 안)은 한 픽셀도 다르지 않습니다 — 달라지는 것은 가장자리 띠이고, 필터를 끈 대조 칸에서도 같은 만큼 다릅니다. 측정한 엔진은 Chromium 과 Safari 입니다.

하지 않은 것.

  • 구름의 톤 클램프와 휘도 가중. 배경을 평균내기 전에 그 휘도로 가중하는 값입니다. 가중 평균은 나눗셈이 필요한데, SVG 필터 프리미티브에는 8비트 중간값 위의 표 조회 말고는 나눗셈이 없고 그 표는 만들지도 재지도 않았습니다.
  • 림의 음영과 림의 빛. 배경 휘도로 켜지고 꺼지는 가장자리 띠입니다. 휘도를 읽는 손잡이는 Web 에 있습니다 — 몸체 음영이 표면의 backdrop-filter 목록 안에서 그것을 씁니다. 하지만 이 두 값은 윤곽을 따라 도는 띠에만 걸리는 값입니다. 윤곽 모양의 층을 유리 옆에 형제로 두고 자기 backdrop-filter 로 휘도를 뒤집으면 띠 위의 픽셀이 배경의 휘도를 따라 뒤집히지만(측정), 같은 층을 유리 안에 두면 페이지를 보지 못합니다. 표면은 요소 하나라서 그 형제는 컴포넌트가 내야 하거나(표면을 그리는 모든 컴포넌트의 구조가 바뀝니다) 런타임이 끼워 넣어야 합니다(굴절의 변위 맵처럼 하이드레이션 이후에야 생깁니다). 그 구조를 정하기 전에는 열지 않습니다.

"iOS 27" 수치 프리셋#

CoreSurfaceTuning.ios27 은 iOS 27 의 유리 재질처럼 읽히는 값 묶음입니다. 스타일이 아니라 상수 하나입니다 — CoreSurfaceStyleId 에 멤버를 더하지 않으므로 Figma 모드도, 프리셋 스냅샷도, 패리티 칸도 생기지 않고, 쓰지 않은 앱은 Flutter 에서 같은 테마를, Web 에서 같은 스타일시트를 받습니다. 배경 패스를 그리는 스타일은 liquidGlass 하나라 그 스타일에만 적용됩니다.

// Flutter
CoUIApp(
  surfaceStyle: CoreSurfaceStyleId.liquidGlass,
  surfaceTuning: CoreSurfaceTuning.ios27,
  theme: appTheme,
  home: const HomePage(),
)
// Web
ThemeConfig(
  palette,
  surfaceTunings: const {
    CoreSurfaceStyleId.liquidGlass: CoreSurfaceTuning.ios27,
  },
)

모든 숫자는 디자이너 판단 값입니다. 레퍼런스 구현에서 가져와 이 킷의 한계 안에서 맞춘 출발점이고, 어느 것도 네이티브 재질을 측정해서 얻은 값이 아닙니다. 각각의 대안은 CoreSurfaceTuning.ios27 한 줄을 바꾸는 일입니다.

값설정판단
블러 surfaceBlur: 0.6 사본이 가장자리를 지키는 약한 블러라 뒤의 글자가 옅은 유령으로 읽힙니다. 대안: 프리셋의 4 (유령이 안개가 됩니다)
채도 라이트 2 · 다크 1.4 레퍼런스 구현은 라이트에서 2.1 을 씁니다. 이 킷의 상한( maxSurfaceSaturation )이 2 라 그 안에 둡니다 — 상한을 올리는 건 이 값의 일이 아닙니다
빛 라이트 lightAngle: 0(바로 위) · 다크 180(바로 아래) 프리셋의 기본은 -45
굴절 법칙 paraxial 띠의 폭 · 세기 · 굴절률은 프리셋 그대로이고, 이 법칙의 이동은 굴절률 − 1 에 비례하므로 그 세기에서 다시 봐야 합니다
서리 σ 14 · 라이트 몫 0.73 · 다크 몫 0.85 다크에서는 사본이 희미해 구름을 더 짙게 둡니다
몸체 음영다크 0.28, 라이트 없음어두운 재질에서 흰색이 72%(255 중 184)로 나옵니다

말하지 않는 것: 채움. 네이티브의 어두운 재질은 거의 투명해(채움 12% 안팎) 이 킷이 minSurfaceOpacity(0.4) 로 두는 하한 아래입니다. 하한은 텍스트 대비의 바닥이 무언가에 대한 것이도록 있는 값이라 프리셋은 채움 쌍(라이트 0.5 · 다크 0.6)을 그대로 둡니다. 그래서 다크 표면은 네이티브보다 채움이 짙고, 어두운 재질이 밝은 배경을 눌러 주는 몫은 채움이 아니라 몸체 음영이 맡습니다. 이 프리셋은 색 토큰을 바꾸지 않으므로 대비 감사의 팔레트 주장은 기본 팔레트의 것입니다.

굴절의 법칙#

굴절 띠는 두 법칙 중 하나로 휩니다. 둘은 같은 띠(폭 · 휘는 정도 · 굴절률 · 색 분산)를 읽고, 띠 안의 이동 곡선만 다릅니다.

snell (기본)paraxial
모델 베벨의 법선에 스넬의 법칙을 적용해, 꺾인 광선의 화면 방향 성분만큼 샘플을 옮깁니다 얇은 프리즘 근사 — 편향은 (굴절률 − 1) × 기울기이고, 기울기는 띠 안쪽으로 간 비율 h 의 1 − h² 입니다
띠 안의 모양 림에서 가장 세고, 안쪽 경계에서 0 으로 사라지도록 페이드합니다 림에서 가장 세고, 포물선으로 줄어 안쪽 경계에서 0 에 닿습니다
굴절률이 1 일 때샘플이 움직입니다움직이지 않습니다
굴절률이 커지면이동이 줄어듭니다 (1 / 굴절률)이동이 커집니다 (굴절률 − 1)

프리셋의 값에서는 snell 이 림에서 더 세고 몇 픽셀 안쪽부터는 paraxial 이 더 셉니다. 두 곡선이 림 가까이에서 교차하므로 한 법칙에서 고른 strength 는 다른 법칙에서 다시 봐야 합니다 — 굴절률에 의존하는 방향이 반대이기 때문입니다.

디자이너 판단 값. paraxial 의 이동 규모는 snell 과 같은 단위(띠 폭 × strength × 고정 배수)에 굴절률 − 1 을 곱한 값이고, 림에서 두 법칙이 같아지도록 맞추지 않습니다. 맞추면 법칙을 바꿔도 림의 세기가 유지되는 대신 굴절률 − 1 이라는 의존성을 잃습니다.

어느 법칙이 맞는지는 룩의 결정이지 품질의 문제가 아닙니다. 기본은 지금 그려지는 모양 그대로(snell)라서 손잡이를 쓰지 않은 앱의 화면은 바뀌지 않습니다. 면 전체를 얕은 돔처럼 확대하는 렌즈는 법칙을 따르지 않습니다.

법칙은 Flutter 의 프래그먼트 셰이더에는 프로그램의 입력 하나로, Web 에는 변위 맵을 굽는 함수의 입력으로 전달됩니다. 두 곳 모두 같은 Core 함수의 곡선을 그리고, 맵의 캐시 키가 법칙을 포함하므로 법칙을 바꾸면 맵이 새로 구워집니다.

범위를 벗어난 값은 컴파일 에러입니다. 조용히 잘라내면 앱이 적은 값과 화면 값이 달라지고 아무 신호도 남지 않기 때문입니다. 예를 들어 surfaceOpacity: 1.0 은 빌드되지 않습니다 — 불투명하면 배경이 없어 블러와 채도가 아무 일도 하지 않는데, 프리셋은 계속 반투명이라고 선언하게 됩니다.

정할 수 없는 것#

띠의 색, 표면의 종류, 그리고 접근성 계약은 프리셋의 진술이라 열려 있지 않습니다.

띠 색이 특히 그렇습니다 — 색이 토큰으로 남아 있어야 고대비 모드가 그것을 같이 옮깁니다. 임의 색을 받으면 다른 모든 것이 고대비로 이동할 때 띠만 제자리에 남습니다.

다음은 열려 있지 않습니다. 두 플랫폼이 같은 모양으로 그릴 수 없는 것을 한쪽에만 열면 같은 코드가 다른 화면을 내기 때문입니다.

  • 서리의 톤 클램프와 휘도 가중. 위 "서리와 몸체 음영" 의 "하지 않은 것" 에 이유가 있습니다.
  • 림의 음영과 림의 빛. 같은 절에 이유가 있습니다.
  • "iOS 27" 이라는 이름의 스타일. 수치 묶음은 CoreSurfaceTuning.ios27 로 열려 있지만 스타일 id 가 아닙니다. 어떤 룩이 옳은지는 디자이너의 판단이라 프리셋의 기본값을 옮기지 않고, 모든 손잡이의 기본값은 "말하지 않음" 입니다.

프레임이 늦을 때 — 품질 적응#

유리는 프레임마다 값을 치릅니다. 굴절과 backdrop 패스는 표면마다 뒤를 다시 읽기 때문에, 표면이 많거나 기기가 느리면 디스플레이의 속도를 지키지 못합니다. 앱이 adaptiveQuality 를 진술하면 프레임이 늦는 만큼 표면이 재질의 일부를 내려놓고, 여유가 생기면 되찾습니다.

진술하지 않으면 아무것도 달라지지 않습니다. 재질을 바꾸는 일이라 측정에 맡길지는 앱이 정합니다. 쓰지 않은 앱은 Flutter 에서 같은 테마를, Web 에서 같은 스타일시트를 받고 프레임을 하나도 재지 않습니다.

// Flutter
CoUIApp(
  surfaceStyle: CoreSurfaceStyleId.liquidGlass,
  adaptiveQuality: const CoreAdaptiveQuality(),
  theme: appTheme,
  home: const HomePage(),
)

// Web — 같은 객체
CoUIWeb(
  theme: appTheme,
  adaptiveQuality: const CoreAdaptiveQuality(),
  child: const App(),
)

단계#

단계그리는 것내려놓은 것
full스타일이 진술한 재질 전부—
noRefraction 반투명 채움 · 블러 · 채도 · 휘도 · 서리 · 몸체 음영(스타일이 진술했다면) · 엣지 띠 · 림 가장자리의 굴절
solid 불투명 채움 · 엣지 띠 · 림 backdrop 패스 전부 — 블러 · 채도 · 휘도 · 서리 · 몸체 음영, 그리고 그 위에서만 뜻이 있는 반투명 채움

solid 는 투명도 줄이기의 대체 모양과 같습니다. 두 이유가 한 결과로 가는 것이라 두 번 쓰지 않고, 투명도 줄이기가 켜져 있으면 앱이 무엇을 진술했든 가장 낮은 단계가 이깁니다. 단계는 아래의 것을 더 내려놓을 뿐 스타일이 진술하지 않은 것을 그리게 하지 않습니다.

순서는 코드가 아니라 데이터입니다(CoreQualityLadder.standard). 디자이너 판단 값: 굴절이 먼저 내려갑니다. 굴절은 프래그먼트 셰이더(Flutter)나 요소마다의 변위 필터(Web)를 따로 요구하는 유일한 효과이고, 내려놓아도 스타일로 읽히는 부분(블러와 반투명 채움)이 남기 때문입니다. 반대 순서는 룩으로 표현되지 않습니다 — 굴절은 구부릴 뒤가 있어야 합니다. 어느 쪽이 싼지는 기기에 따라 달라서 아래 "측정으로 정하는 값"의 도구로 정합니다. 이 레포에서 잰 것은 이렇습니다 — Chrome 에서는 굴절이 유리 한 페이지 비용의 대부분이라 첫 단계가 거의 다 되찾고, Apple M3 Max 의 Flutter 프로파일 모드에서는 굴절이 유리 장면 래스터 시간의 10 % 안팎까지(겹친 카드 5장에서는 5분의 1), backdrop 패스가 다시 10 %에서 3분의 1이라 첫 단계는 더 싼 손실이고 둘째 단계가 더 큰 절감입니다.

앱이 정할 수 있는 것#

항목뜻기본
forced단계를 고정합니다. 아무것도 재지 않습니다없음
initialQuality 이전 실행이 정착한 단계에서 시작해 워밍업을 건너뜁니다. 저장은 앱의 몫입니다 — onQualityChanged 로 알고 여기로 돌려줍니다 없음
lowest내려가지 않을 가장 낮은 단계사다리의 끝
allowStepUp 내려놓은 단계를 되찾아도 되는가. 디자이너 판단 값: 켜 둡니다 — 한 번 느렸다고 세션 내내 룩을 잃으면 안 되기 때문입니다. 끄면 내려가기만 하는 래칫이 됩니다 true
targetFrameRate 디스플레이가 아니라 앱이 목표로 하는 주사율(Hz). 120 Hz 패널 위의 60 fps 앱처럼 디스플레이의 것
tuning거버너가 결정하는 숫자(아래)아래 표
onQualityChanged 단계가 바뀐 뒤 불립니다. 무엇을 재서 그렇게 정했는지가 인자에 담깁니다 없음

단계는 AdaptiveQualityScope.of(context) 로 읽고(없으면 full), 앱 자신의 무거운 일(모델 로딩, 큰 가져오기)은 AdaptiveQualityScope.holdMeasurement(context) 로 감쌉니다 — 유리와 같은 기기를 두고 다투는 느려짐이 유리 탓으로 읽히지 않게 하는 손잡이입니다. 거버너가 돌지 않는 앱에서도 호출은 그대로 되고 아무것도 잡지 않습니다.

final hold = AdaptiveQualityScope.holdMeasurement(context);
try {
  await importLargeFile();
} finally {
  hold.release();
}

무엇을 재는가#

두 플랫폼이 볼 수 있는 것이 달라서 재는 것도 다릅니다. 판정은 coui_core 한 곳이고, 두 플랫폼은 표본만 대며 같은 표로 검증됩니다.

FlutterWeb
재는 값 래스터 시간 — 굴절과 backdrop 패스가 도는 스레드, 단계가 줄일 수 있는 유일한 구간 프레임 간격 — requestAnimationFrame 콜백 사이. 브라우저는 프레임이 무엇을 치렀는지 알려 주지 않고, 따라가지 못하는 컴포지터는 다음 프레임을 늦춥니다
주사율 뷰가 알려 주는 디스플레이의 것 처음 60 간격에서 추정합니다 (targetFrameRate 가 있으면 그것)
언제 재는가 엔진이 프레임 시간을 넘겨주는 동안 — 그릴 것이 없으면 프레임도 없습니다 포인터 · 스크롤 · 키 · 전환 · 긴 프레임 같은 움직임의 신호가 있을 때와 그 뒤 유예 동안만. 쉬는 페이지를 깨워 두지 않기 위해서입니다
멈추는 때 투명도 줄이기가 켜진 동안 · 앱이 백그라운드에 있던 뒤(워밍업부터 다시) 투명도 줄이기가 켜진 동안 · 숨은 탭(돌아오면 워밍업부터 다시)

임계값도 지표마다 다릅니다. 비용은 싼 프레임에서 예산 아래로 내려가 여유를 보이지만, 간격은 정시면 정확히 한 예산이고 놓치면 예산의 정수배라 넘침만 보이기 때문입니다.

어떻게 정하는가#

처음에는 버리고(시작 직후의 첫 라우트 · 셰이더 컴파일 · 아직 올라오지 않은 클록이 기기로 읽히지 않게), 워밍업 동안 모은 분포로 시작 단계를 정합니다. 그 뒤에는 창마다 판정합니다 — 빨리 내려가고 천천히 올라옵니다. 놓친 프레임은 바로 보이고 그 비용인 룩은 한 칸 아래에 있지만, 사용자가 시키지 않은 룩의 변화는 필요 없던 한 칸보다 나쁘기 때문입니다.

한 칸 올라간 직후 다시 넘치면 그 올라감은 실패로 치고, 다음 올라감까지의 기다림을 두 배로 늘립니다(상한 있음). 그러지 않으면 한계에 걸친 기기가 두 단계 사이를 오갑니다. 같은 이유로 한 번 바뀐 뒤에는 쿨다운 동안 다시 바뀌지 않습니다.

숫자값이유
버리는 시간1.5 초첫 라우트 · 셰이더 컴파일 · 클록
워밍업3 초, P75일시적인 튐에 시작 단계가 끌려가지 않게
판정 창2 초, P95사용자가 느끼는 것은 평균이 아니라 드문 큰 지연이므로
내려가기넘친 창 2 연속한 번의 튐으로 룩을 잃지 않게
올라가기여유 있는 창 10 연속내려가기의 다섯 배
올라간 뒤 시험5 창그 안에 다시 넘치면 그 올라감은 실패 — 다음 올라감을 두 배로 기다립니다
쿨다운8 초
비용의 임계 (Flutter)예산의 1.0 넘침 · 0.5 아래 여유비용이 예산을 넘으면 정의상 디스플레이를 놓칩니다
간격의 임계 (Web)예산의 1.5 넘침 · 1.15 아래 여유정시면 1, 하나 놓치면 2 — 그 사이가 1.5 입니다
Web 유예2 초관성 스크롤이나 전환이 끝까지 보일 만큼, 쉬는 페이지는 쉬는 만큼

위의 숫자 전부가 디자이너 판단 값입니다. 유도된 것이 아니라 출발점이고, 모두 CoreQualityTuning 의 default… 상수 하나씩에서 나옵니다. 시간은 프레임 수가 아니라 디스플레이 시간의 초로 적혀 있어 60 Hz 에서나 120 Hz 에서나 같은 시간입니다. 바꾸고 싶은 앱은 tuning 으로 자기 값을 진술합니다.

측정으로 정하는 값

이 숫자들을 기기에서 정하려면 장면별 · 단계별 프레임 분포가 필요하고, 그것을 재는 도구가 tool/frame_timing 입니다(Flutter 프로파일 모드와 Chrome). 도구와 한 번 돌린 결과의 읽는 법은 레포의 tool/frame_timing/frame_timing.dart --help 에 있습니다. 숫자는 그 기기의 것입니다 — 같은 장면이 다른 GPU 에서는 다른 곡선을 그립니다.

접근성이 먼저입니다#

신호두 플랫폼에서
투명도 줄이기 가장 낮은 단계(solid)가 이깁니다. 앱이 무엇을 진술했든, 거버너가 무엇을 골랐든. 그동안 재지 않고, 끝나면 워밍업부터 다시 합니다
고대비 · 강제 색단계가 건드리지 않습니다. 이 모드들이 이미 끄는 것(굴절)은 단계가 더 끄지 않고, 그리지 않던 것을 단계가 되살리지도 않습니다
동작 줄이기 단계의 전환에 모션이 없습니다. Flutter 는 한 번에 바꾸고, Web 은 채움이 테마 전환처럼 페이드하지만 동작 줄이기에서는 즉시 바뀝니다

어디까지 닿는가#

닿는 것은 스타일이 진술한 재질로 만들어진 표면과 그 재질을 읽는 컴포넌트입니다. 닿지 않는 것:

  • 호출자가 컨테이너에 직접 진술한 굴절. 스타일의 진술이 아니라 호출자의 진술이라서입니다.
  • 앱이 직접 쓴 블러(BackdropFilter · backdrop-filter).
  • 앱 바의 스크롤 가장자리 그라디언트. backdrop 패스가 아니라 비용이 다르지 않습니다.
  • Web 내비게이션 바의 움직이는 렌즈. 자기 광학을 따로 붙입니다.
  • 스타일이 재질을 진술하지 않은 표면. 내려놓을 것이 없습니다.

표면끼리 backdrop 을 공유하지 않습니다. 공유하면(BackdropGroup) 블러 N 개가 backdrop 한 벌을 읽습니다. 블러만 있는 표면으로 잰 상한은 크게 줄어듭니다 — Apple M3 Max 의 Flutter 프로파일 모드에서 50 장이 프레임당 래스터 약 13 ms 에서 0.5 ms 로 내려갑니다. 그래도 하지 않는 이유는 CoUI 의 표면이 블러만이 아니고 서로 겹치기 때문입니다. backdrop 사본 하나를 읽는 표면은 앞에 그려진 유리의 결과를 읽지 못해 겹친 유리가 서로를 흐리지 못할 수 있고, 굴절 셰이더의 좌표는 backdrop 을 읽는 pass 의 것이라 공유가 그 pass 의 원점을 바꾸는지가 기기에서 확인되지 않았습니다. 확인되지 않은 것을 표면의 기본 동작으로 두지 않습니다. 상한은 tool/frame_timing 의 blur-plain-N 과 blur-group-N 장면으로 다시 잴 수 있습니다.

두 가지는 알아 두셔야 합니다. Web 은 굴절을 그리지 못하는 브라우저를 구분하지 못합니다 — Safari 와 Firefox 는 굴절 필터를 읽고 "지원한다" 고 답하면서 그리지 않아서, 거기서 noRefraction 은 화면을 바꾸지 않고 창 하나와 쿨다운 하나를 씁니다. 그리고 거버너는 느린 프레임이 유리 탓인지 모릅니다. 유리가 없는 앱이 켜면 아무것도 바뀌지 않으면서 룩만 잃습니다 — 유리를 그리는 앱만 켜세요.

유리 패널이 나타나고 사라질 때#

유리 패널은 나타나고 사라지는 동안에도 뒤를 흐립니다. 팝오버 엔진이 띄우는 패널(팝오버 · 드롭다운 · 셀렉트 · 컨텍스트 메뉴 · 호버 카드 · 툴팁)은 페이드할 때 패널 전체에 불투명도를 걸지 않고, 표면이 자기 재질의 항을 진행도에 맞춰 줄입니다.

왜 불투명도로 페이드하지 않나#

1 미만의 불투명도는 아래를 별도의 레이어에 저장합니다. 그 안의 BackdropFilter 는 그 레이어에 그려진 것만 읽고, 거기에는 아무것도 없습니다. 그래서 불투명도로 페이드하는 유리 패널은 페이드의 첫 프레임에 블러 · 채도 · 굴절을 잃고 반투명한 판으로 남으며, 뒤의 페이지는 그 프레임에 또렷해집니다. 아래 "위쪽 레이어가 자기 pass 를 저장하는 자리" 와 같은 원인이 좌표를 틀어 놓는 대신 읽을 것을 없애는 모양입니다.

2 논리 픽셀 폭의 줄무늬 위에 유리 팝오버를 띄우고 열리는 중간(진행도 0.5)에 세워서, 패널 가운데에 줄무늬가 페이지의 몇 배로 남는지 쟀습니다(macOS Impeller, 배율 2).

페이드하는 방법가운데에 남는 줄무늬
패널 전체에 불투명도를 겁니다페이지의 0.75
표면이 자기 재질을 줄입니다페이지의 0.03
다 열린 패널페이지의 0.01 미만

무엇이 무엇을 따르나#

패널을 페이드하는 쪽은 진행도를 패널의 표면에 내려 줍니다. 표면이 backdrop pass 를 그리면 페이드를 넘겨받고, 그동안 패널 위에 불투명도를 걸지 않습니다.

항따르는 것
채움 · 테두리 · 콘텐츠 · 가장자리 빛 · 림 · 그림자 진행도 그대로 — 불투명도가 하는 일입니다. 채움과 콘텐츠를 덮는 불투명도는 pass 를 감싸지 않고 옆에 놓입니다
블러 · 서리의 몫 · 몸체 음영 · 굴절의 세기와 렌즈 CoreSurfaceDissolve.share(진행도) 만큼, 0 에서 스타일이 진술한 값까지
채도 · 휘도 조정같은 몫만큼, 항등(1)에서 진술한 값까지
굴절의 띠 폭 · 굴절률 · 분산의 비율움직이지 않습니다. 어디서 어떤 각도로 굽는지를 말하는 값이고, 굽는 세기는 이미 0 으로 갑니다

어느 항도 다른 항이 끝나기를 기다리지 않습니다. 블러가 채움이 0 이 될 때까지 온전했다가 마지막 프레임에 사라지면 같은 결함을 반대쪽에서 낸 것입니다. 곡선은 Core 가 한 곳에서 정하고 모든 항이 같은 곡선을 읽습니다.

진행도 1 에서는 페이드가 없는 표면과 같은 필터 · 같은 레이어를 그립니다 — macOS Impeller 에서 다 열린 패널의 캡처가 한 픽셀도 다르지 않았습니다. 진행도 0 에서는 아무것도 그리지 않습니다.

디자이너가 정하는 값#

상수값바꾸면
CoreSurfaceDissolve.materialExponent 1 재질의 몫은 진행도 ^ materialExponent 입니다. 1 은 재질이 채움과 같은 걸음으로 줄어듭니다(절반 진행에서 블러와 굴절이 절반). 1 보다 크면 유리가 채움보다 먼저 성격을 잃어 판이 사라지는 것으로 읽히고, 작으면 채움보다 오래 흐림을 지녀 유리가 사라지는 것으로 읽힙니다. 0 이하는 허용하지 않습니다 — 재질이 마지막 프레임까지 온전했다가 튀어 사라집니다

누가 이 방법을 쓰고, 누가 쓰지 않나#

표면이 pass 를 그리지 않는 패널은 가릴 pass 가 없으므로 패널 전체를 불투명도로 페이드합니다. 불투명 스타일 · solid 품질 단계 · 투명도 줄이기가 그렇습니다.

표면이 pass 를 그리는지는 표면이 자기 재질로 해석한 값에서 한 함수가 답합니다. 스타일 축 · 품질 단계 · 접근성 설정이 이미 반영된 블러가 그 값이므로, 패널을 페이드하는 쪽은 같은 조건을 다시 계산하지 않습니다. 다시 계산하면 두 답이 갈리는 날 패널이 한 번도 페이드하지 않거나 두 번 페이드합니다.

도달 범위 — 여기까지만 참입니다#

  • 팝오버 엔진이 띄우는 패널에만 닿습니다. 패널에 직접 불투명도를 거는 다른 코드는 위 첫 절의 결함을 그대로 갖습니다.
  • 패널의 루트가 키트의 표면이어야 합니다. 앱이 직접 그린 상자 안에 키트의 유리 표면이 있으면, 그 표면이 페이드를 넘겨받아 표면만 페이드하고 바깥 상자는 페이드하지 않습니다. 같은 패널의 루트에 표면이 나란히 둘 이상이고 일부만 pass 를 그리는 경우도 같습니다 — pass 를 그리는 쪽이 페이드를 넘겨받고 나머지는 페이드하지 않습니다.
  • Web 은 이 값을 읽지 않고, 패널의 유리는 페이드 동안 흐림을 잃는 것으로 읽힙니다. 브라우저는 요소 자신의 불투명도를 그 요소의 backdrop-filter 를 적용한 뒤에 겁니다 — 필터를 단 요소에 opacity: .5 를 걸면 줄무늬가 페이지의 0.50 으로 남습니다(블러된 판과 선명한 페이지의 교차 페이드). 조상의 불투명도는 그 안의 backdrop-filter 를 격리합니다 — 같은 줄무늬가 0.875 로 남고, 이는 필터 없는 판의 값과 같습니다. 오버레이 패널은 열고 닫는 키프레임(불투명도와 크기)을 패널을 감싼 모션 상자에 걸므로, 그 안의 유리는 키프레임 동안 페이지를 읽지 못합니다. Chrome 156(macOS, 배율 1)에서 같은 키프레임을 복제한 페이지로 쟀고, 실제 사이트의 패널은 재지 않았습니다.

플랫폼 비대칭#

FlutterWeb
반투명 · 블러 · 채도 · 휘도 · 엣지 시인✅✅
모드마다 다른 채도 · 빛의 방향 ✅ 테마를 만들 때 그 모드의 값으로 접습니다 ✅ 다크 규칙으로 다시 진술합니다. 림의 빛은 하이드레이션 이후 런타임이 시트의 진술을 읽어 씁니다
윤곽 림✅ (Flutter Web 포함)✅, 하이드레이션 이후 — 서버가 굽는 마크업에는 없고 클라이언트가 요소를 재서 씁니다
페이드하는 동안의 배경 흐림 ✅ 팝오버 엔진이 띄우는 패널 — 표면이 자기 항을 진행도에 맞춰 줄입니다 열고 닫는 키프레임이 패널을 감싼 상자에 걸려, 그 안의 유리가 키프레임 동안 흐림을 잃는 것으로 읽힙니다(위 "도달 범위")
굴절네이티브 Impeller 만Chromium 만, 하이드레이션 이후
굴절의 법칙굴절이 그려지는 곳에서만굴절이 그려지는 곳에서만
서리 ✅ 재질 아래의 형제 backdrop 패스(셰이더 없음). 측정한 엔진은 Impeller(Metal) Chromium 만 ✅ — backdrop-filter 목록 안의 SVG 필터. Safari · Firefox 는 그 목록을 통째로 그리지 않아서 시트가 이 항을 빼고 나머지 유리를 그립니다(위)
몸체 음영 셰이더 필터가 도는 네이티브 엔진만 — 굴절과 같은 프로그램이라 Flutter Web 에서는 그려지지 않습니다 Chromium 만 ✅ — backdrop-filter 목록 안의 SVG 필터. Safari · Firefox 는 그 목록을 통째로 그리지 않아서 시트가 이 항을 빼고 나머지 유리를 그립니다(위)
축을 정하는 손잡이앱 루트 생성자 필드SurfaceStyleScope.setSurfaceStyle
축을 읽는 손잡이 SurfaceStyleScope.of SurfaceStyleScope.of
품질 적응 (adaptiveQuality) 래스터 시간을 재서 테마를 접는 자리에서 단계를 반영 프레임 간격을 재서 문서 속성 data-coui-quality 를 씀 — 스타일시트와 굴절 호스트가 읽고 리빌드는 없음
품질 단계의 서버 렌더 해당 없음 forced · initialQuality 는 <html> 에 구워져 첫 페인트부터 그 단계. 측정은 브라우저에서만

재질 자체는 양 플랫폼이 같은 값을 같은 곳에서 읽습니다.

다크가 OS 설정으로만 정해지는 정적 페이지. 문서의 스타일은 <html data-coui-style> 에 달립니다. 다크 규칙의 OS 설정 형태(:root:not([data-theme="light"]) [data-coui-style=…])는 스타일을 후손 선택자로 찾는데 <html> 자신에 달린 스타일은 후손이 아니라서 매칭되지 않습니다 — data-theme 가 없는 페이지는 OS 가 다크여도 라이트 재질을 그립니다(실측). 휘도와 채움의 다크 규칙이 같은 형태라 모드마다 다른 채도와 빛도 같은 한계를 따릅니다. data-theme 속성 형태, 다크 스코프 형태, 그리고 자기 재질 클래스를 단 표면의 OS 설정 형태는 영향이 없습니다.

굴절은 요청이지 보장이 아닙니다#

프리셋은 굴절을 진술합니다 — 띠의 폭(짧은 변의 비율) · 휘는 정도 · 색 분산. 값은 크기 무관이라 축에 살 수 있고, 실제 변위는 표면마다 렌더러가 자기 사각형에서 다시 계산합니다. 컴포넌트는 자기 크기를 재지 않습니다.

그릴 수 있는지는 값이 아니라 엔진의 성질입니다. 그리지 못하는 자리에서 표면은 나머지 재질(반투명 · 블러 · 채도 · 띠)을 그대로 지니고 휘지만 않습니다 — 한 겹이 없다고 재질 전체를 잃는 쪽이 더 나쁜 답이라서입니다.

런타임굴절왜
Flutter 네이티브 (Impeller)✅프래그먼트 셰이더가 backdrop 을 옮깁니다
Flutter Web❌그 SDK 가 필요한 API 를 무조건 throw 로 정의합니다
Web · Chromium✅ (하이드레이션 이후)SVG 변위 필터. 서버가 굽는 마크업에는 없고, 클라이언트가 붙습니다
Web · Safari · Firefox❌값을 파싱해서 "지원한다" 고 답하지만 그리지는 않습니다. 기능 감지로는 구분되지 않습니다

Flutter 의 첫 프레임. 셰이더는 비동기로 도착하므로, 프로그램이 아직 없는 프로세스에서 첫 표면이 그려지는 프레임은 휘지 않고 다음 프레임에 휩니다. CoUIApp 은 스타일이 굴절을 진술하는 것을 아는 순간 프로그램을 미리 요청해 두므로 그 뒤에 마운트되는 표면은 첫 프레임부터 휩니다. 앱의 첫 화면에 유리가 있다면 runApp 전에 한 번 기다리면 됩니다 — Web 이 하이드레이션 전에는 그리지 못하는 것과 같은 자리의, Flutter 쪽 답입니다.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SurfaceRefractionProgram.warmUp(); // 못 그리는 엔진에서는 즉시 돌아옵니다
  runApp(const MyApp());
}

위쪽 레이어가 자기 pass 를 저장하는 자리. Flutter 네이티브에서 표면 위의 레이어가 자기 pass 를 저장하고 그 pass 가 루트 원점에서 시작하지 않으면 — ImageFiltered · Frame 의 layerBlur, Clip.antiAliasWithSaveLayer 클립, 루트 원점이 아닌 곳에서 시작하는 클립 아래의 Opacity · ShaderMask · ColorFiltered · BackdropFilter — 엔진이 셰이더에 루트가 아니라 그 pass 의 좌표를 넘겨 띠가 표면이 아닌 곳에 그려집니다. 표면은 그 자리에서 휘지 않고 나머지 재질만 그립니다. 클립이 없거나 루트 원점에서 시작하는 클립만 위에 있는 Opacity · ShaderMask(페이지 전환의 페이드, 스크롤 영역 바깥에서 가장자리를 흐리는 FadeScroll)는 pass 가 루트 원점에서 시작하므로 그대로 휩니다. 판정은 장면을 만들 때마다 다시 하므로, 페이드가 끝나 레이어가 사라지면 그때부터 휩니다. 이 규칙은 Impeller(Metal)에서 잰 것입니다. 몸체 음영은 좌표를 재지 않는 점 연산이라 그 자리에서도 같은 양으로 그려지고, 서리는 재질의 패스와 형제 패스라 같은 패스를 읽어 그려집니다 — 둘 다 같은 Impeller 에서 잰 것입니다.

고대비 모드와 투명도 줄이기에서는 굴절도 함께 꺼집니다 — 경계를 판독 가능하게 하려고 켠 모드가 그 뒤를 휘게 할 이유가 없습니다. 서리와 몸체 음영도 같은 이유로 꺼지고, Web 에서는 forced-colors 도 같습니다. 아래 품질 적응의 noRefraction 단계도 같은 굴절을 내려놓지만, 그것은 접근성이 아니라 프레임이 늦어서입니다.

함께 보기#

  • 테마 — 색·타이포그래피 등 축 바깥의 테마 시스템
  • 디자인 토큰 — 색·타입·간격·형태·효과·모션 맵
  • 색상 — 시맨틱 색 토큰과 팔레트