--- url: /api-sdk/sdk/gameplay-js/ui.md description: managed UI 5개 컴포넌트의 옵션과 표시 정책 --- # 게임플레이 JS SDK UI 이 문서는 `Gameplay.UI` 가 제공하는 5개 컴포넌트의 옵션과 표시 정책을 다룹니다. SDK 설치와 `init()` 은 [설치·초기화](/api-sdk/sdk/gameplay-js/), `Gameplay.UI` 외의 메서드는 [전체 메서드](/api-sdk/sdk/gameplay-js/reference)를 참고해 주세요. 게임플레이 JS SDK는 managed UI를 SDK 번들 안에 포함합니다. `Gameplay.init()` 을 호출하면 별도 스크립트를 추가로 로드하지 않아도 managed UI가 함께 준비되며, 기본적으로 활성 상태입니다. navigation의 닫기 버튼, 더보기 메뉴, 이탈 확인 modal은 SDK가 managed UI로 제공하는 필수 범위입니다. 게임웹뷰에서 반복적으로 필요한 이 3가지 요소는 SDK가 전담하므로, 게임이 직접 만들지 않습니다. managed UI를 직접 호출해야 하는 경우 `Gameplay.UI` 를 사용합니다. 컴포넌트별 네임스페이스 5개로 구성됩니다. ```ts type GameplayUiFacade = { Navigation: GameplayNavigationApi; Toast: GameplayToastApi; Modal: GameplayModalApi; BottomSheet: GameplayBottomSheetApi; Loader: GameplayLoaderApi; }; ``` 호출 형태는 다음과 같습니다. ```ts Gameplay.UI.Toast.show('저장되었습니다.'); Gameplay.UI.Loader.show(); Gameplay.UI.Loader.hide(); ``` ## 공통 규칙 **핸들** — `open()`·`show()` 는 그 오버레이만 조작하는 핸들을 돌려줍니다. 네임스페이스의 `close()` 는 현재 열린 것을 닫고, 핸들은 자기가 연 것만 다룹니다. ```ts type GameplayUiHandle = { close(): void; destroy(): void; update(next: TOptions): void; }; ``` `update()` 는 부분 갱신입니다. 주지 않은 필드는 현재 값이 그대로 남으므로, 로딩만 끄거나 높이만 바꾸는 호출이 나머지 옵션을 지우지 않습니다. `close()` 는 닫힘 애니메이션을 재생한 뒤 사라지고, `destroy()` 는 즉시 제거합니다. 화면 전환처럼 애니메이션을 기다릴 수 없는 상황에만 `destroy()` 를 쓰세요. **버튼** — bottom sheet와 modal의 버튼은 같은 타입을 받습니다. ```ts type GameplayUiActionColor = 'gray' | 'yellow'; type GameplayUiAction = { label: string; color?: GameplayUiActionColor; onClick?: (handle: GameplayUiHandle) => Promise; }; type GameplayUiSecondaryAction = Omit, 'color'>; ``` **콜백은 자기 오버레이의 핸들을 받습니다** — 인자로 들어오므로 `handle` 을 변수에 담아 두지 않아도 됩니다. ```ts Gameplay.UI.BottomSheet.open({ title: '보상 받기', primaryAction: { label: '받기', onClick: async (sheet): Promise => { sheet.update({loading: true}); await claimReward(); }, }, }); ``` 인자를 쓰지 않아도 됩니다. 콜백 안에서 그 오버레이를 건드릴 일이 없으면 그냥 받지 않으세요. **콜백은 `Promise` 를 반환합니다** — `onClick`, `onConfirm`, `onCancel`, 더보기 항목의 `onClick` 이 모두 같습니다. 한 줄짜리 동기 처리에도 `async` 를 붙여 주세요. SDK는 콜백이 끝날 때까지 기다린 뒤 다음 동작을 이어갑니다. 저장이나 로그 전송처럼 시간이 걸리는 작업을 넣어도 됩니다. `primaryAction` 과 `secondaryAction` 을 각각 주거나 빼서 **버튼 하나만, 둘 다, 또는 버튼 없이** 구성합니다. `secondaryAction` 은 `color` 를 받지 않습니다: 강조는 화면당 하나여야 하므로 보조 버튼은 항상 `gray` 입니다. ::: tip 버튼 색은 꾸밈이 아니라 위계입니다 **`gray` 가 기본입니다.** 위계의 기준이 되는 색이고 어떤 액션에도 쓸 수 있습니다. 달리 고를 이유가 없다면 `gray` 를 쓰세요. **`yellow` 는 그 화면에서 우선순위가 분명히 높은 액션에만 씁니다.** 사용자가 이어서 해야 할 일이 하나로 좁혀지는 자리입니다. 눈에 띄게 하려고 붙이는 색이 아닙니다: 노란 버튼이 흔해지면 정작 중요한 자리에서 구별되지 않습니다. ::: **텍스트만 받습니다** — `title`·`content`·`description` 은 문자열만 받아 텍스트로 렌더합니다. HTML 문자열이나 DOM 노드를 넣어도 마크업으로 해석되지 않습니다: 게임이 주입한 값이 그대로 실행되면 웹뷰의 네이티브 브리지가 노출될 수 있기 때문입니다. **겹치는 순서** — 위에서부터 loader → toast → modal → bottom sheet → navigation 입니다. | 컴포넌트 | 위치 | 이유 | | --- | --- | --- | | Loader | 가장 위 | 막는 것이 역할입니다. `maxDuration` 이 지나면 스스로 닫혀 화면이 영구히 잠기지 않습니다 | | Toast | modal 위 | 읽히는 것이 전부인 짧은 알림이라, 가리면 사용자가 못 본 채 사라집니다 | | Modal · BottomSheet | 중간 | 사용자의 답을 기다리는 화면입니다 | | Navigation | 가장 아래 | modal이나 bottom sheet가 떠 있는 동안에는 닫기 버튼이 눌리면 안 됩니다 | navigation이 가장 아래인 것은 의도입니다. 나가기 확인 팝업 자체가 modal이므로, navigation이 위에 있으면 팝업이 떠 있는 상태에서 닫기 버튼을 다시 눌러 `onClick` 이 또 실행됩니다. 더보기 목록은 navigation 안에 그려지므로 버튼과 같은 층입니다. **안전영역** — 모든 컴포넌트는 상하 안전영역을 뺀 영역을 기준으로 배치됩니다. 노치·홈 인디케이터 위치를 게임이 계산하지 않아도 됩니다. 게임 페이지의 viewport 메타태그에 `viewport-fit=cover` 가 필요합니다. 없으면 브라우저가 안전영역 값을 `0` 으로 보고하므로 컴포넌트가 노치나 홈 인디케이터에 가릴 수 있습니다. ```html ``` ## Navigation ::: info 문서에서 바로 띄워 보기 아래 버튼은 이 페이지에서 실제 SDK를 실행합니다. 브라우저 창 전체가 게임 화면이라고 생각하고 보세요. ::: ```ts type GameplayNavigationApi = { update(options?: GameplayNavigationControlsOptions): void; show(): void; hide(): void; updateDropdownItem(id: string, patch: GameplayNavigationDropdownItemPatch): void; }; type GameplayNavigationDropdownItemPatch = Partial>; type GameplayNavigationControlsOptions = { visible?: boolean; backgroundColor?: string; contentElement?: Element | null; exitAction?: { onClick?: () => Promise; onConfirm?: () => Promise; onCancel?: () => Promise; }; keepBrowserAction?: | false | { onClick?: () => Promise; }; dropdown?: { items?: Array; }; }; type GameplayNavigationDropdownItem = { id: string; label: string; disabled?: boolean; onClick?: (item: GameplayNavigationDropdownItemHandle) => Promise; }; type GameplayNavigationDropdownItemHandle = { update(patch: GameplayNavigationDropdownItemPatch): void; }; ``` 화면 우상단에 떠 있는 제어 UI입니다. 게임이 직접 만들 필요가 없으며, 구성만 바꿉니다. 기본으로 `init()` 시점에 표시됩니다. `init({ui: {navigation: {visible: false}}})` 으로 숨긴 채 시작할 수 있고, 실행 중에는 `show()` · `hide()` 로 바꿉니다. 자세한 것은 아래 [표시 제어](#표시-제어)를 참고해 주세요. | 옵션 | 타입 | 설명 | | --- | --- | --- | | `visible` | `boolean` | 기본값 `true`navigation을 표시할지. 구성과는 다른 축이라 숨긴 채 구성만 해둘 수 있습니다 | | `backgroundColor` | `string` | 상단 44px 띠의 배경색. `#RGB`·`#RRGGBB`·`#RRGGBBAA` 만 받습니다 | | `contentElement` | `Element \| null` | 게임 영역 요소. 주면 버튼이 기기 화면이 아니라 이 요소 안 우상단에 붙습니다 | | `exitAction` | `{onClick?, onConfirm?, onCancel?}` | 나가기 버튼을 눌렀을 때 실행할 작업 | | `keepBrowserAction` | `false \| {onClick?}` | iOS 플로팅 전환 버튼. `false` 면 숨기며, 지원하지 않는 환경에서는 자동으로 숨겨집니다 | | `dropdown` | `{items?}` | 더보기 메뉴에 덧붙일 항목 | `backgroundColor` 를 주지 않으면 배경이 투명이라 버튼만 떠 있고, 게임 화면이 상태바까지 올라옵니다. 색을 주면 상단 띠가 그 색으로 채워집니다. 형식에 맞지 않는 값은 예외를 던지지 않고 무시합니다: 색 하나 때문에 navigation이 사라지는 편이 더 나쁘기 때문입니다. ::: tip 디자인 가이드 `backgroundColor` 를 지정하면 **태블릿의 좌우 레터박스도 같은 색으로 맞춰 주세요.** 모바일 전용 게임을 태블릿에서 실행하면 게임 화면 좌우에 레터박스가 생깁니다. 이때 navigation 띠와 레터박스를 서로 다른 색으로 두면 화면 상단이 색이 갈린 조각들로 보여, 게임 화면이 잘려 붙은 것처럼 읽힙니다. 두 색을 같게 두면 상단이 하나의 면으로 이어집니다. 레터박스 대응 기준은 [게임 공통 제작 가이드: 태블릿 대응](/docs/design/common-guide#태블릿-대응)을 참고해 주세요. ::: ### 게임 영역 기준 배치 태블릿처럼 가로가 긴 기기에서 게임이 화면 가운데만 쓰면 좌우에 레터박스가 생깁니다. 이때 navigation 은 **기기 화면** 기준으로 붙습니다: 안전영역 안쪽 우상단입니다. 게임 영역은 그보다 훨씬 안쪽이므로 버튼이 레터박스 위에 놓이고, [디자인 가이드](/docs/design/common-guide#레터박스-기본-배경-컬러)가 레터박스를 검은색(`#000000`)으로 규정하므로 어두운 톤의 버튼이 검은 배경에 묻혀 눈에 잘 띄지 않습니다. 게임 영역을 감싸는 요소를 `contentElement` 로 넘기면 navigation 과 더보기 목록이 그 안으로 들어옵니다. ```ts const stage: Element | null = document.querySelector('#stage'); Gameplay.init({ gameName: '샘플 게임', gameCode: 'SampleGame', appId: 123456, ui: {navigation: {contentElement: stage}}, }); // 실행 중에 바꾸려면 Gameplay.UI.Navigation.update({contentElement: stage}); ``` 넘기지 않으면 지금까지처럼 기기 화면 우상단이 기준이므로, 기존 게임의 동작은 달라지지 않습니다. 게임 영역이 화면만큼 넓어지면 자동으로 원래 자리로 돌아갑니다. ::: tip 숫자가 아니라 요소를 넘깁니다 두 가지를 없애기 위해서입니다. 게임은 보통 캔버스 해상도로 폭을 생각하는데 그 값은 `devicePixelRatio` 만큼 CSS 픽셀과 다릅니다. 요소를 넘기면 SDK 가 `getBoundingClientRect()` 로 재므로 언제나 CSS 픽셀이라 이 문제가 사라집니다. 그리고 가로 모드는 높이에 맞춰 만들라는 디자인 가이드에 따라 게임 폭은 화면 높이에서 파생됩니다. 회전하면 값이 달라지므로 한 번 넘긴 숫자는 그 순간 틀린 값이 됩니다. 요소는 `ResizeObserver` 로 따라가므로 회전·분할 화면에서 다시 넘길 필요가 없습니다. ::: ::: info 잘못된 값은 무시합니다 `contentElement` 로 쓸 수 없는 값이 오면 **예외를 던지지 않고 기본 위치**(기기 화면 안전영역 안쪽 우상단)로 둡니다. * 셀렉터가 빗나가 `null` 이 온 경우 * 아직 DOM 에 붙지 않은 요소 * 화면 전환 중이라 `display: none` 이거나 면적이 0 인 요소 마지막 경우는 요소가 다시 면적을 가지면 자동으로 게임 영역 기준으로 돌아옵니다. navigation 이 사라지면 사용자가 게임에서 나갈 길을 잃으므로, 배치를 포기할지언정 UI 는 유지합니다. ::: ::: warning 좌우 대칭 레터박스를 전제합니다 좌우 여백이 서로 다른 레이아웃은 지원하지 않습니다. 디자인 가이드가 좌우 대칭 레터박스를 규정하므로 일반적인 구성에서는 문제가 되지 않습니다. ::: ::: info 개별 버튼은 끌 수 없습니다 나가기 버튼과 더보기를 따로 숨기거나 배치를 바꾸는 옵션은 제공하지 않습니다. 나가는 길과 공유·문의 동선은 SDK가 책임지는 최소 범위이기 때문입니다. 배치도 우상단 하나뿐이라 고를 필요가 없습니다. navigation **전체**의 표시 여부는 아래 [표시 제어](#표시-제어)로 바꿉니다. ::: ### 표시 제어 `visible` · `show()` · `hide()` 세 가지가 표시를 정합니다. 구성을 바꾸는 `update()` 는 표시를 건드리지 않습니다. | 호출 | 하는 일 | | --- | --- | | `init({ui: {navigation: {visible: false}}})` | 숨긴 채 시작합니다 | | `Gameplay.UI.Navigation.show()` | 그때까지 쌓인 구성 그대로 표시합니다 | | `Gameplay.UI.Navigation.hide()` | 숨깁니다. 구성은 남아 있어 다시 `show()` 하면 그대로 돌아옵니다 | | `Gameplay.UI.Navigation.update({...})` | 구성만 바꿉니다. **숨긴 상태에서 불러도 뜨지 않습니다** | 표시와 구성이 다른 축이라, 구성은 미리 해두고 숨긴 채 시작할 수 있습니다. ```ts await Gameplay.init({ gameName: '게임 이름', gameCode: 'sample-game', appId: 1234567, ui: {navigation: {visible: false, backgroundColor: '#000000'}}, }); // 숨긴 동안에도 구성은 바꿀 수 있습니다. 뜨지는 않습니다. Gameplay.UI.Navigation.update({dropdown: {items: [{id: 'guide', label: '게임 방법'}]}}); // 이때 위에서 준 구성 그대로 뜹니다. Gameplay.UI.Navigation.show(); ``` ::: danger 숨긴 동안에는 사용자가 게임웹뷰를 벗어날 수 없습니다 **나가기(닫기) 버튼이 navigation 안에 있습니다.** 숨긴 동안 게임이 자체 종료 동선을 제공하지 않으면 사용자가 게임에서 빠져나갈 방법이 없습니다. 연출 때문에 잠깐 숨겼다면 **반드시 되돌리세요.** 특히 숨긴 상태에서 오류나 예외로 흐름이 끊기면 `show()` 가 영영 호출되지 않을 수 있으니, 복구 경로를 함께 두세요. 종료 경로가 있는지는 SDK가 보장하지 못하므로 심사에서 확인합니다. 자체 UI를 이미 가진 게임이 SDK를 단계적으로 들일 때 쓰는 값이며, 새로 연동하는 게임은 `visible` 을 건드리지 마세요. ::: ### 나가기 버튼 닫기 버튼을 누르면 SDK가 확인 팝업을 띄운 뒤 게임웹뷰를 닫습니다. 이 흐름 전체를 SDK가 갖습니다. ```text 탭 → onClick → 확인 팝업 → 확인 / 취소 → onConfirm / onCancel → 닫기 / 팝업 해제 ``` 게임이 정할 수 있는 것은 각 지점에서 실행할 작업뿐입니다. 팝업을 띄우는 것도, 닫는 것도, 웹뷰를 종료하는 것도 SDK가 합니다. | 필드 | 타입 | 설명 | | --- | --- | --- | | `onClick` | `() => Promise` | 버튼을 누른 직후. 확인 팝업을 열기 전에 실행합니다 | | `onConfirm` | `() => Promise` | 확인을 눌렀을 때. **웹뷰를 닫기 전에 실행하며 끝날 때까지 기다립니다** | | `onCancel` | `() => Promise` | 취소를 눌렀을 때 | **팝업 문구는 게임이 바꿀 수 없습니다.** | 요소 | 값 | | --- | --- | | 제목 | `init({gameName})` 으로 전달한 게임 이름 | | 본문 | `게임을 그만할까요?` | | 버튼 | `취소` · `확인` | 모든 게임에서 같은 화면이어야 하는 곳입니다. 게임마다 문구가 다르면 사용자가 무엇을 누르는지 매번 다시 읽어야 합니다. ```ts Gameplay.UI.Navigation.update({ exitAction: { onConfirm: async (): Promise => { await saveProgress(); }, }, }); ``` `onConfirm` 이 끝난 뒤에 웹뷰가 닫히므로, 종료 직전의 저장이나 로그 전송을 여기에 넣을 수 있습니다. ### 플로팅 전환 버튼 `keepBrowserAction` 은 게임웹뷰를 플로팅 뷰로 바꾸는 버튼입니다. 눌리면 SDK가 `keepBrowser()` 를 호출합니다. **이 기능을 지원하지 않는 환경에서는 `false` 를 주지 않아도 SDK가 알아서 숨깁니다.** 게임이 톡 버전이나 플랫폼을 확인해 분기할 필요가 없습니다. 버튼이 빠지면 남은 버튼이 그 자리를 채웁니다. 위 예시가 자동으로 숨겨진 상태입니다. ### 더보기 메뉴 더보기 메뉴에는 `공유하기` 와 `문의하기` 가 항상 들어가고, `items` 로 준 항목이 그 아래에 붙습니다. 두 기본 항목의 동작은 SDK가 갖습니다. 메뉴는 **navigation 의 우측 끝**을 기준으로 열립니다. 더보기 버튼 자체가 아니라 맨 오른쪽 버튼의 우측 끝에 맞으므로, 접기 버튼이 있든 없든 열리는 자리가 같습니다. | 필드 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `id` | `string` | 필수 | 항목 식별자 | | `label` | `string` | 필수 | 표시 문구. 10자를 넘으면 말줄임 처리됩니다 | | `disabled` | `boolean` | | 비활성 여부 | | `onClick` | `(item: GameplayNavigationDropdownItemHandle) => Promise` | | 눌렀을 때 | `disabled` 는 선언할 때만 정하는 값이 아닙니다. 항목 하나만 고치는 방법이 두 가지 있습니다. **콜백 안에서는 인자로 받은 항목을 고칩니다.** 진행 중에 같은 항목이 다시 눌리는 것을 막을 때 씁니다. ```ts Gameplay.UI.Navigation.update({ dropdown: { items: [ { id: 'reward', label: '보상 받기', onClick: async (item): Promise => { item.update({disabled: true}); await claimReward(); item.update({disabled: false}); }, }, ], }, }); ``` 메뉴는 항목을 선택하는 순간 닫히므로, 켜 둔 `disabled` 는 그 자리에서 보이지 않고 **사용자가 메뉴를 다시 열었을 때** 보입니다. **콜백 밖에서는 `updateDropdownItem()` 을 씁니다.** ```ts Gameplay.UI.Navigation.updateDropdownItem('reward', {disabled: true}); ``` `label` 을 다시 주지 않아도 남고, 다른 항목은 건드리지 않습니다. 없는 `id` 를 주면 아무 일도 하지 않으므로, 조건에 따라 항목이 없을 수 있는 화면에서 존재를 먼저 확인하지 않아도 됩니다. 메뉴가 열려 있어도 닫히지 않습니다. 상황에 따라 항목을 잠그는 것이 이 옵션의 쓰임이므로, 잠글 때마다 메뉴가 사라지면 쓸 수 없기 때문입니다. ::: info update() 와의 차이 `update({dropdown: {items}})` 는 **목록 전체를 다시 선언**합니다. 항목을 추가하거나 빼거나 순서를 바꿀 때 사용하세요. 값 하나만 바꿀 때 이 방법을 쓰면 나머지 항목과 `label` 까지 모두 다시 적어야 합니다. ::: ## Toast ```ts type GameplayToastApi = { configure(options: GameplayToastConfig): void; show(message: string, options?: GameplayToastOptions): GameplayToastId; dismiss(toastId?: GameplayToastId): void; }; type GameplayToastConfig = { maxVisible?: number; position?: 'top' | 'bottom'; }; type GameplayToastOptions = { duration?: number; }; type GameplayToastId = string; ``` 짧은 안내를 화면 하단에 띄웁니다. 지정한 시간이 지나면 스스로 사라지므로 게임이 닫지 않아도 됩니다. | 옵션 | 타입 | 설명 | | --- | --- | --- | | `message` | `string` | 필수표시할 문구. `show()` 의 첫 인자로 전달합니다 | | `duration` | `number` | 기본값 `5000`표시 시간(ms). 최대 `5000` 이며 더 큰 값을 주면 `5000` 으로 잘립니다 | | `maxVisible` | `number` | 기본값 `1`동시에 보이는 개수. 최대 `3` 이며 더 큰 값을 주면 `3` 으로 잘립니다. `configure()` 로 설정하며 초과분은 오래된 것부터 사라집니다 | | `position` | `'top' \| 'bottom'` | 기본값 `'bottom'`붙는 쪽. `configure()` 로 설정합니다 | `maxVisible` 을 늘리면 toast가 아래에서 위로 쌓입니다. 개수를 넘으면 오래된 것부터 사라집니다. **개수를 넘겨 밀려나는 toast는 퇴장 연출 없이 바로 사라집니다.** 페이드가 남으면 방금 띄운 toast와 사라지는 toast가 그 시간 동안 겹쳐 보입니다. 스스로 시간이 다 되어 닫히는 toast는 그대로 퇴장 연출을 씁니다: 다 읽고 사라지는 것과 새 알림에 밀려나는 것은 다른 사건입니다. `position: 'top'` 은 `'bottom'` 을 축만 뒤집은 것입니다. 화면 위에 붙고, 위에서 내려오며, 아래로 쌓입니다. ```ts Gameplay.UI.Toast.configure({position: 'top'}); ``` toast 하나하나가 아니라 **전체 설정**입니다. `show()` 에는 줄 수 없습니다: toast마다 방향이 다르면 화면에 위아래로 갈린 두 무리가 남고, `maxVisible` 이 어느 쪽을 세는지 알 수 없어집니다. **값을 바꾸면 떠 있던 toast는 이전 위치에서 닫힙니다.** 새 위치로 옮기지 않습니다: 옮기면 다음 `show()` 에서 그 toast들이 새 스택의 초과분으로 계산돼, 방금 띄운 것도 아닌 toast가 밀려나는 잔상이 보입니다. 게임 시작 시 한 번 정하고 쓰는 것을 권합니다. ::: tip 디자인 가이드 `position` 은 `'top'` · `'bottom'` 중 **하나만 골라 게임 전체에서 그것만 쓰세요.** 두 위치를 상황에 따라 번갈아 쓰면 사용자가 안내를 어디서 봐야 하는지 알 수 없어, 뜬 줄도 모르고 지나치는 toast가 생깁니다. 위치를 하나로 두면 그 자리가 "안내가 뜨는 곳" 으로 학습됩니다. 바꿔야 할 이유가 없다면 기본값 `'bottom'` 을 그대로 쓰는 것을 권합니다. ::: `'top'` 의 기준선은 화면 상단이 아니라 **navigation 의 하단**입니다. navigation 이 표시된 상태라면 toast 가 그 아래에서 시작하므로 버튼을 가리지 않습니다. | navigation 상태 | `'top'` toast 의 기준선 | | --- | --- | | 표시됨 (기본값, 또는 `Navigation.show()` 호출 후) | navigation 띠의 하단 | | 표시되지 않음 (`visible: false` 이거나 `hide()` 호출 후) | 화면 상단 안전영역 | 기준선은 `setStatusBarOverlay` 값과 무관하게 navigation 과 같은 값을 씁니다. 전체화면을 켜고 끄면 navigation 과 toast가 함께 움직이므로 둘의 간격은 유지됩니다. ::: warning navigation 을 띄우지 않는 게임은 상단이 비어 있지 않을 수 있습니다 `visible: false` 로 navigation 을 띄우지 않으면 toast 가 화면 상단 안전영역까지 올라옵니다. 게임이 그 자리에 자체 UI(점수·재화 표시 등)를 두었다면 toast 가 그 위를 덮습니다: 다만 toast 는 탭을 가로채지 않으므로 가려진 버튼도 그대로 눌리며, 가리는 시간은 길어야 `duration` 만큼입니다. ::: `show()` 가 돌려주는 ID를 `dismiss()` 에 넘기면 그 toast만 닫습니다. 인자 없이 `dismiss()` 를 호출하면 열려 있는 toast를 모두 닫습니다. ```ts const toastId: GameplayToastId = Gameplay.UI.Toast.show('보상이 지급되었습니다.'); Gameplay.UI.Toast.dismiss(toastId); ``` ## Modal ```ts type GameplayModalApi = { open(options?: GameplayModalOptions): GameplayUiHandle; confirm(options?: GameplayConfirmOptions): GameplayUiHandle; close(): void; }; type GameplayModalOptions = { title?: string; description?: string; primaryAction?: GameplayUiAction; secondaryAction?: GameplayUiSecondaryAction; dismissible?: boolean; }; type GameplayConfirmOptions = Omit & { confirmLabel?: string; cancelLabel?: string; onConfirm?: (handle: GameplayUiHandle) => Promise; onCancel?: (handle: GameplayUiHandle) => Promise; }; ``` 화면 가운데에 뜨는 대화상자입니다. 웹뷰 화면의 한가운데에 놓입니다: `setStatusBarOverlay` 로 전체화면을 켜면 상태바 영역까지 포함한 가운데, 끄면 그 아래 영역의 가운데입니다. | 옵션 | 타입 | 설명 | | --- | --- | --- | | `title` | `string` | 제목 | | `description` | `string` | 설명 | | `dismissible` | `boolean` | 기본값 `false`배경을 탭해 닫을 수 있는지. bottom sheet와 기본값이 반대입니다 | | `primaryAction` | [`GameplayUiAction`](#type-gameplayuiaction) | 주 버튼. 주지 않으면 닫기만 하는 `확인` 버튼이 자동으로 들어갑니다 | | `secondaryAction` | [`GameplayUiSecondaryAction`](#type-gameplayuisecondaryaction) | 보조 버튼 | modal은 버튼 없이 열 수 없습니다. `primaryAction` 을 주지 않으면 SDK가 `확인` 버튼을 넣습니다: 닫을 방법이 없는 modal이 남는 것을 막기 위함입니다. **본문이 길면 본문 영역만 스크롤되고 제목과 버튼은 제자리에 남습니다.** 본문 영역의 최대 높이는 `512px` 이며, 넘치면 그 안에서 세로로 스크롤됩니다. `title` 은 스크롤 영역 밖에 있어 `512px` 에 포함되지 않고, 본문을 내려도 그대로 보입니다. 무엇에 대한 팝업인지가 사라지지 않게 하기 위함입니다. 제목은 2줄까지만 표시됩니다. 화면이 `512px` 보다 낮으면 본문 영역이 그만큼 줄어듭니다. 버튼이 화면 밖으로 밀려나지 않게 하기 위함이므로, 짧은 화면에서는 스크롤이 더 일찍 생깁니다. ::: tip 긴 안내는 bottom sheet가 더 맞습니다 modal은 결정을 묻는 자리입니다. 읽을 것이 많아 스크롤이 필요한 내용이라면 [BottomSheet](#bottomsheet) 를 쓰는 편이 사용자에게 자연스럽습니다. ::: `confirm()` 은 확인·취소 두 버튼을 갖춘 modal을 짧게 여는 형태입니다. 버튼 객체 대신 문구와 콜백만 넘깁니다. | 옵션 | 타입 | 설명 | | --- | --- | --- | | `confirmLabel` | `string` | 기본값 `'확인'`확인 버튼 문구 | | `cancelLabel` | `string` | 기본값 `'취소'`취소 버튼 문구 | | `onConfirm` | `(handle: GameplayUiHandle) => Promise` | 확인을 눌렀을 때 | | `onCancel` | `(handle: GameplayUiHandle) => Promise` | 취소를 눌렀을 때 | `confirm()` 은 항상 버튼 두 개로 열리므로, 버튼이 하나인 대화상자가 필요하면 `open()` 을 쓰세요. ```ts Gameplay.UI.Modal.confirm({ title: '아이템을 사용할까요?', description: '보유한 부활권 1개가 소모됩니다.', confirmLabel: '사용', onConfirm: async (): Promise => { await useRevivalTicket(); }, }); ``` ::: info 게임을 나가는 확인 창은 직접 만들지 않습니다 닫기 버튼을 누르면 SDK가 확인 팝업을 띄운 뒤 웹뷰를 닫습니다. 같은 화면을 `confirm()` 으로 또 만들면 문구가 게임마다 달라집니다. [나가기 버튼](#나가기-버튼)을 참고해 주세요. ::: ## BottomSheet ```ts type GameplayBottomSheetApi = { open(options?: GameplayBottomSheetOptions): GameplayUiHandle; close(): void; }; type GameplayBottomSheetHeight = 'auto' | 'full' | number; type GameplayBottomSheetImage = { src: string; alt: string; }; type GameplayBottomSheetOptions = { title?: string; dismissible?: boolean; height?: GameplayBottomSheetHeight; loading?: boolean; image?: GameplayBottomSheetImage; content?: string; primaryAction?: GameplayUiAction; secondaryAction?: GameplayUiSecondaryAction; }; ``` 화면 아래에서 올라오는 시트입니다. 본문이 길면 시트가 커지는 대신 본문 영역 안에서만 스크롤됩니다. | 옵션 | 타입 | 설명 | | --- | --- | --- | | `title` | `string` | 상단 제목. 한 줄을 넘으면 말줄임 처리됩니다 | | `content` | `string` | 본문 텍스트 | | `height` | [`GameplayBottomSheetHeight`](#type-gameplaybottomsheetheight) | 기본값 `'auto'`아래 표 참고 | | `dismissible` | `boolean` | 기본값 `true`배경을 탭하거나 아래로 끌어 닫을 수 있는지 | | `loading` | `boolean` | 기본값 `false`본문 위에 스피너를 덮고 버튼을 비활성합니다 | | `image` | [`GameplayBottomSheetImage`](#type-gameplaybottomsheetimage) | 본문 맨 위에 놓을 이미지 | | `primaryAction` | [`GameplayUiAction`](#type-gameplayuiaction) | 주 버튼 | | `secondaryAction` | [`GameplayUiSecondaryAction`](#type-gameplayuisecondaryaction) | 보조 버튼. 색을 받지 않습니다 | `height` 는 세 가지 형태를 받습니다. | 값 | 동작 | | --- | --- | | `'auto'` | 내용이 높이를 정합니다. 아래 최소·최대 범위 안으로 맞춰집니다 | | `'full'` | 최대 높이 | | `number` | 그 픽셀값. 역시 최소·최대 범위 안으로 맞춰집니다 | 최소 높이는 `224px`, 최대 높이는 `화면 높이 - (상단 안전영역 + 44px)` 입니다. 기기마다 안전영역이 달라 실제 픽셀값도 다릅니다. **이 최대 높이는 navigation 표시 여부와 `setStatusBarOverlay` 값에 관계없이 같습니다.** 전체화면을 켜고 끄더라도 시트의 천장은 움직이지 않습니다. 왼쪽이 `'full'`, 오른쪽이 `420` 입니다. 어느 쪽이든 본문이 넘치면 시트가 커지는 대신 본문 영역 안에서만 스크롤됩니다. `image` 를 주면 본문 맨 위에 이미지가 붙습니다. | 필드 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | | `src` | `string` | 필수 | 이미지 주소. `https:`, `blob:`, `data:image/...` 만 통과합니다 | | `alt` | `string` | 필수 | 대체 텍스트. 장식용이면 빈 문자열을 넣습니다 | 이미지는 크기 제한이 없습니다. 시트 폭을 넘으면 비율을 지킨 채 줄어들고, 넘지 않으면 원래 크기로 가운데에 놓입니다. 세로로 길면 본문과 함께 스크롤되며, 버튼 영역은 이미지 유무와 관계없이 같은 자리에 남습니다. 왼쪽이 `1280 x 720`, 오른쪽이 `720 x 1280` 입니다. 두 경우 모두 버튼은 같은 자리에 있습니다. ::: warning 주의사항 `src` 는 `http:` 를 받지 않습니다. 게임 페이지가 HTTPS로 열리므로 혼합 콘텐츠로 차단되기 때문입니다. 형식에 맞지 않으면 예외를 던지지 않고 이미지 영역만 그리지 않으므로, 이미지가 보이지 않으면 주소의 스킴을 먼저 확인하세요. ::: ```ts Gameplay.UI.BottomSheet.open({ title: '보상 받기', content: '일일 미션을 모두 완료했습니다. 지금 보상을 받으시겠습니까?', secondaryAction: {label: '다음에'}, primaryAction: { label: '받기', color: 'yellow', onClick: async (sheet): Promise => { sheet.update({loading: true}); await claimReward(); }, }, }); ``` 버튼을 누르면 콜백이 끝난 뒤 시트가 스스로 닫힙니다. 콜백 안에서 `close()` 를 부르지 않아도 됩니다. `loading` 을 켠 뒤 다시 끌 때 `height` 를 주지 않으면 현재 높이가 유지됩니다. 로딩이 끝나는 순간 시트 높이가 튀지 않게 하기 위함입니다. ## Loader ```ts type GameplayLoaderApi = { show(options?: GameplayLoaderOptions): GameplayUiHandle; hide(): void; }; type GameplayLoaderOptions = { maxDuration?: number; }; ``` 화면 가운데에 스피너를 띄웁니다. 네트워크 요청처럼 끝을 알 수 없는 대기 구간에 사용합니다. loader는 모든 UI보다 위에 놓입니다. modal이 떠 있는 상태에서 호출하면 위 예시처럼 modal 위에 겹칩니다. | 옵션 | 타입 | 설명 | | --- | --- | --- | | `maxDuration` | `number` | 기본값 `10000`자동으로 닫히는 시간(ms). 최대 `10000` | loader는 항상 뒤쪽 탭을 막습니다. 끄는 옵션은 없습니다: 막지 않으면 대기 중에도 사용자가 계속 누를 수 있어, 응답이 오는 사이 같은 동작이 여러 번 들어갑니다. `maxDuration` 은 상한이자 기본값입니다. 응답이 오지 않아 `hide()` 를 부르지 못하는 상황에서도 스피너가 화면에 영구히 남지 않도록, 시간이 지나면 SDK가 닫습니다. ## 참고 문서 * [설치·초기화](/api-sdk/sdk/gameplay-js/): SDK 설치와 `init()` * [전체 메서드](/api-sdk/sdk/gameplay-js/reference): `Gameplay.UI` 외의 메서드와 에러 코드 전체 명세 * [심사 체크리스트](/docs/checklist/review-checklist): 연동 완료 후 자체 점검 항목 * 기술 문의: [카카오디벨로퍼스 데브톡](https://devtalk.kakao.com/c/game-play/353)