스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
시스템 전반에서 앱의 제어 기능 확장하기
앱의 제어 기능을 제어 센터, 잠금 화면 등에 적용해 보세요. WidgetKit을 사용하여 앱의 제어 기능을 시스템 환경으로 확장하는 방법을 알아봅니다. 제어기를 빌드하고, 구성 가능하게 만들고, 제어기의 디자인을 다듬는 방법도 살펴보세요.
챕터
- 0:00 - Introduction
- 0:37 - Learn about controls
- 3:04 - Build a control
- 6:39 - Update toggle states
- 12:25 - Make controls configurable
- 14:40 - Add refinements
리소스
- Adding refinements and configuration to controls
- Creating a camera experience for the Lock Screen
- Creating controls to perform actions across the system
- Forum: App & System Services
- Human Interface Guidelines: Controls
- Updating controls locally and remotely
관련 비디오
WWDC24
-
다운로드
안녕하세요, 저는 Cliff입니다 System Experience 팀의 엔지니어죠 오늘은 iOS 18의 새로운 위젯인 제어 기능을 빌드하는 방법을 알아보겠습니다 먼저 제어 기능이 무엇인지 어떻게 사용하는지 알아봅니다 그런 다음 제어 기능을 빌드하는 방법과 동작을 수행하고 상태를 유지하고 구성을 지원하고 시스템과의 통합을 개선하는 방법을 보여드리겠습니다
제어 기능을 사용하면 앱의 기능을 확장할 수 있습니다 시스템 공간에서 제어 센터, 잠금 화면 동작 버튼 등으로 확장 가능하며 WidgetKit을 사용하여 만들 수 있습니다 iOS 14에서 도입된 WidgetKit은 앱이 시각적으로 풍부한 맞춤형 스타일 콘텐츠를 자세한 정보와 함께 표시할 수 있도록 하여 위젯으로 날씨나 다음 캘린더 이벤트를 확인할 수 있도록 합니다 iOS 18은 제어 기능 추가로 WidgetKit을 더욱 확장했습니다 제어 기능을 사용하면 앱에서 동작에 빠르게 액세스할 수 있습니다 동작과 간결한 정보에 초점을 맞추기 때문에 손전등을 켜거나 시계 앱에 딥링크를 사용하는 등 제어 기능을 편리하고 멋지게 사용할 수 있습니다 위젯을 만드는 방법과 제어 기능을 만드는 법은 비슷하며 동일한 기본 아키텍처를 사용합니다 제어 기능에는 두 가지 유형이 있는데 버튼과 토글입니다 버튼은 앱 실행과 같은 개별 작업을 수행하는 반면 토글은 불리언 상태를 변경하여 무언가를 켜거나 끄는 일을 합니다
대화형 위젯과 마찬가지로 제어 기능은 앱 인텐트로 동작을 수행합니다
기본적으로 제어 기능은 앱에서 제공하는 정보를 사용해 시스템 공간에서 시각적 형태로 표현되는 동작입니다
앱은 시스템에 기호 제목, 색조 색상과 추가 콘텐츠를 제공합니다 그런 다음 지원되는 시스템 공간에 제어 기능을 추가할 수 있으며 시스템 공간은 상황에 맞게 제어 기능을 표시합니다
제어 센터에서 제어 항목은 세 가지 크기 중 하나로 표시될 수 있어서 제목과 값 텍스트가 표시되지 않는 경우도 있습니다 일할 때 집중하고 휴식도 제때 취할 수 있어 타이머를 사용하는 것을 좋아합니다! 오늘은 작업과 휴식 시간을 일정 간격으로 나눠주는 생산성 타이머 제어 기능을 만들어 보겠습니다 타이머가 실행되고 있으면 실시간 현황에 남은 시간이 표시됩니다
잠금 화면에서 타이머를 시작할 수 있고
제어 센터에서 중지할 수 있으며
동작 버튼으로 타이머를 시작하고 중지할 수도 있습니다
이 타이머 제어 기능을 완전히 처음부터 만들어 볼 텐데요 기본 토글부터 시작해서 제어 기능의 풍부한 특징들을 활용해볼 생각입니다
기존의 생산성 위젯을 위한 WidgetBundle이 이미 있으니 이 WidgetBundle에 TimerToggle() 항목을 추가하여 시작하겠습니다 그런 다음 동일한 위젯 확장에서 TimerToggle() 제어를 정의하기 위해 ControlWidget을 따르는 TimerToggle 유형을 추가합니다 제어 기능을 정의하기 위해 제어 기능에 표시할 정보와 수행할 동작을 제공하겠습니다
StaticControlConfiguration부터 시작하겠습니다 이 제어 기능은 구성할 수 없다는 의미입니다 구성은 나중에 추가하겠습니다
제어 기능은 고유 식별자로 kind를 사용하고 제어 기능 유형에 대한 정의로 ControlWidgetToggle을 사용합니다
그런 다음 제목을 지정하고 상태를 제공하겠습니다
제어 기능이 상호작용할 때 수행할 동작도 제공합니다 대화형 위젯과 마찬가지로 제어 기능은 앱 인텐트를 사용해 동작을 실행합니다
마지막으로 제어 기능을 정의하는 기호 이미지를 제공합니다
이제 필요한 모든 정보가 있으니 시스템에서 제어 기능을 표시할 수 있습니다
제어 센터에 제어 기능을 배치하면 제목, 기호 켜기/끄기 상태가 표시됩니다
타이머가 실행 중이거나 멈췄을 때 다른 기호를 표시한다면 이 제어 기능이 더 멋져질 겁니다
이를 위해 클로저에 isOn 인수를 사용하여 흐르는 ‘모래시계’ 기호가 표시되면 제어 기능이 켜져 있고 시간이 흘러가는 중임을 나타내겠습니다
훌륭합니다 이제 제어 기능은 실행 중일 때만 흐르는 모래시계 기호를 표시합니다
또한 이 상태의 값 텍스트도 개선하고 싶습니다 지금은 켜짐과 꺼짐인데요
이 둘은 토글의 기본적인 값 텍스트이지만 사람들은 타이머의 상태를 켜짐 또는 꺼짐보다는 실행 중 또는 정지로 생각합니다
제어 기능의 값 텍스트는 Image를 값 텍스트와 systemImage를 모두 포함한 Label로 변경하여 맞춤화할 수 있습니다 이제 제어 기능에 더 적절하고 관련성이 있으며 기능 상태를 설명하는 값 텍스트가 켜짐/꺼짐 텍스트 대신 나옵니다
이러한 값 텍스트가 표시되지 않는 경우가 있는데요 제어 기능이 잠금 화면에 있거나 제어 기능이 제어 센터에서 작은 크기로 표시되어 기호만 표시되는 경우입니다
컨트롤이 전반적으로 마음에 들지만 기호가 켜짐 상태에서 기본 systemBlue 색조를 띠고 있어 제 생산성 앱의 브랜딩과 어울리지 않는 것 같습니다
제어 기능에 기본 systemBlue 대신 독특한 색상을 주려면 색조 색상을 제공합니다
제 생산성 앱의 색상인 보라색을 사용하겠습니다 보라색은 생산성을 상징하죠! 이 색조는 토글이 켜져 있을 때 기호 색조 지정에 사용됩니다
스타일이 전부 적용된 제어 기능입니다 잠금 화면에서 그리고 동작 버튼으로 제어 가능하죠 제어 센터에서 동작하게 하는 코드와 동일한 코드를 사용합니다 제어 기능이 켜지면 기호와 값 텍스트에 지정된 색조 색상이 반영됩니다 토글이 상태를 표시하고 관리하는 방법을 보겠습니다 지금까지 제어 기능에 현재 타이머 상태를 제공하기 위해 TimerManager 클래스를 사용했습니다 이 예제에서는 TimerManager가 제 생산성 앱과 같은 데이터에 액세스하는 공유 그룹 컨테이너에서 데이터를 찾아내며 실행 중인 상태를 동기적으로 가져옵니다 상태나 콘텐츠가 변경될 때 시스템이 제어 기능을 다시 로드하는 방법을 살펴봅시다
시스템에서 제어 기능을 다시 로드해야 하는 경우 위젯 확장 프로세스에서 제어 기능의 본문을 실행하여 제어 기능의 현재 값을 요청하고 제어 기능의 콘텐츠를 생성합니다 그다음 제어 기능 값과 콘텐츠가 다시 시스템으로 전달되어 제어 기능을 표시하는 데 사용됩니다 이제 위젯 확장은 제어 기능의 현재 상태와 해당 상태에 대한 콘텐츠를 제공합니다
시스템에서 제어 기능을 다시 로드하도록 하는 이벤트가 세 가지 있는데 제어 기능 동작이 수행될 때
앱에서 제어 기능을 다시 로드하도록 온디맨드로 요청할 때 푸시 알림이 제어 기능을 무효화할 때입니다 이 중 첫 번째 이벤트는 제어 기능 동작이 수행될 때입니다 사용자가 제어 기능과 상호 작용할 때마다 시스템에서 자동으로 다시 로드하는데 제어 기능 앱 인텐트의 perform() 함수가 반환된 겁니다 반환되기 전에 모든 업데이트를 완료해야 합니다
제 타이머 제어 기능에서 동작은 ToggleTimerIntent()입니다
이 인텐트는 타이머의 “Running” 상태를 설정하고 LiveActivity를 시작하거나 중지합니다
이 앱 인텐트는 SetValueIntent로 타이머의 “Running” 상태를 시스템에서 제공한 값으로 설정하며 타이머 LiveActivity를 수정하므로 LiveActivityIntent입니다 수행 함수가 완료되면 시스템은 제어 기능을 새로운 상태로 업데이트합니다 타이머 제어 기능의 상태 변경은 상호작용하는 방법 외에도 다른 방법이 있습니다 생산성 앱을 열고 거기에서 타이머를 시작하거나 중지하면 됩니다 그리고 제어 기능을 최신 상태로 유지하고 싶습니다
이렇게 하려면 타이머 상태 변경 시 앱이 ControlCenter API를 사용해 제 타이머 제어 기능 종류를 다시 로드할 제어 기능으로 지정해 제어 기능을 새로 고칠 수 있습니다 이제 앱에서 타이머를 시작하면 제어 기능 상태가 최신 상태로 유지됩니다!
제어 기능의 상태나 콘텐츠를 새로 고쳐야 하는 경우 앱이 제어 기능을 다시 로드하도록 시스템에 요청할 수 있습니다 위젯과 실시간 현황에 사용할 수 있는 것과 동일한 새로 고침 도구를 제어 기능에도 사용할 수 있습니다 제어 기능을 개발하면서 상태를 자주 새로 고치려면 개발자 설정에서 WidgetKit 개발자 모드를 활성화해 제어 기능에서 시스템 정책을 제거하세요 이제 이 기기에서 생산성 타이머가 잘 작동합니다 타이머가 여러 기기에서 작동하도록 만들고 모든 기기가 서버의 동일한 상태에 액세스하도록 하고 싶습니다
저의 제어 기능이 반영하는 상태는 기기에서 사용할 수 없는 서버의 상태이므로 타이머 상태를 비동기적으로 가져와야 하며 이를 위해 ValueProvider를 사용할 수 있습니다
ControlValueProvider에는 두 가지 요구 사항이 있습니다 currentValue(), previewValue입니다
currentValue()는 비동기식으로 데이터베이스나 서버와 같이 필요한 곳에서 데이터를 가져올 수 있게 합니다 제 경우에는 TimerManager가 서버에서 타이머 상태를 비동기적으로 쿼리합니다
오류를 발생시켜서 상태를 계산할 수 없음을 시스템에 알릴 수도 있습니다 이는 제어 기능을 나중에 다시 로드해야 함을 나타냅니다
previewValue는 사용자가 표시할 값을 선택하는 곳으로 사용자가 제어 기능을 미리 볼 때 제어 항목 갤러리 같은 곳에서 제어 기능을 추가하기 전에 잠금 화면을 맞춤화할 때 동작 버튼 설정 등에서 활용합니다 previewValue는 미리 결정된 상태로 매우 빠르게 반환되어야 하며 제어 기능의 꺼짐 상태에 해당하는 값이어야 합니다
ValueProvider는 ValueProvider가 필요한 다른 제어 기능 이니셜라이저에서 사용할 수 있으며, 제공된 값은 토글을 정의하는 클로저로 전달됩니다 제 경우에는 이 값을 제어 기능의 isOn 상태로 사용하죠 이 예에서는 단순히 Bool 값을 사용하고 있지만 값에 더 많은 정보가 포함되도록 만들 수도 있습니다 그 예는 나중에 보여드리겠습니다 시스템이 ValueProvider로 제어 기능을 다시 로드할 때 먼저 ValueProvider를 실행해 현재 값을 가져온 다음 이 값을 제어 기능 클로저에 전달해 콘텐츠를 생성합니다 이 모든 것이 이루어지는 곳은 위젯 확장 프로세스입니다
이제 생산성 타이머의 상태가 서버에 저장되므로 다른 기기에서 변경할 수 있습니다 예를 들어 iPad에서 타이머를 시작하거나 중지할 때 이 기기의 외부 상태가 변경되는 경우 다른 기기에서 제어 기능을 다시 로드하도록 만들고 싶습니다 이 경우를 처리하기 위해 푸시 알림 API를 사용하여 제어 기능 푸시 핸들러를 정의하면 외부 상태 변경 푸시 알림이 수신될 때 제어 기능이 다시 로드되도록 구성할 수 있습니다 푸시 처리 문서에 해당 기능을 만들 수 있는 방법과 모범 사례가 자세히 설명되어 있습니다
이제 iPad에서 타이머를 중지하면 iPhone의 제어 기능도 멈춥니다!
생산성 타이머가 잘 작동하고 있지만 업무를 위한 타이머와 바이올린 연습과 같은 개인용 타이머를 따로 설정하여 앱에서 따로 추적하고 싶습니다 각 경우에 대한 제어 기능을 제어 센터에 둘 다 넣을 수 있으면 좋을 것 같습니다 제어 기능은 WidgetKit에서 위젯처럼 빌드되므로 사용자가 제어 기능을 구성할 수 있게 만들면 가능합니다 제어 센터에 타이머 제어 기능 중 하나를 추가한 후 작업 타이머를 시작하고 중지할지 개인 타이머를 시작할지 선택할 수 있게 하고 싶습니다 이렇게 하면 제어 센터에서 작업 타이머와 개인 타이머 각각에 대한 제어 기능을 가질 수 있습니다 먼저 ValueProvider를 업데이트해 새 프로토콜을 준수하게 합니다 AppIntentControlValueProvider죠 값이 인텐트의 구성에 따라 달라지게 만드는 프로토콜입니다 구성을 결정하는 앱 인텐트는 SelectTimerIntent로 사용자가 제어 기능과 상호 작용할 타이머를 선택할 수 있도록 합니다 잘 보시면 제가 이제 구성의 특정 타이머의 실행 상태를 가져오고 반환하는 값은 타이머와 실행 상태를 모두 포함하는 맞춤형 구조체라는 것을 알 수 있습니다
구성 가능한 ValueProvider를 AppIntentControlConfiguration()과 함께 사용해 제어 기능을 구성할 수 있습니다 이제 클로저에 전달된 값은 내 timerState 구조체이며 그 타이머와 실행 상태를 사용해 토글을 완료합니다 특정 타이머의 이름이 제어 기능의 제목으로 표시되고 이제 토글 타이머 앱 인텐트가 특정 타이머에 대해 작동합니다
이제 다른 사람이 제어 센터를 맞춤화할 때 제 제어 기능을 사용하여 상호 작용할 타이머를 선택할 수 있도록 합니다 이제 제어 센터에 별도의 업무용, 개인용 타이머 제어 기능을 나란히 배치해 각각 다른 타이머를 제어할 수 있게 되었습니다!
제어 기능이 작동하기 위해 구성이 필요한 경우 promptsForUserConfiguration() 한정자를 사용해 시스템 공간에 제어 기능을 추가할 때 시스템에서 자동으로 구성하라는 메시지를 표시하게 할 수 있습니다
기능을 더 세분화할 수도 있습니다 시스템의 기본값이 사용 사례에 맞지 않을 때 가장 이해하기 쉽고 관련성 높은 콘텐츠를 제공하게 할 수 있습니다 예를 들어, 동작이 수행되기 전에 동작 버튼과 상호 작용할 때 동작 힌트가 표시됩니다 제 제어 기능의 동작 힌트는 Hold for Running, Hold for Stopped입니다
왜 그런지 좀 더 자세히 살펴보겠습니다
제어 기능의 값 레이블을 “Running”/“Stopped” 텍스트로 맞춤화 전에는 시스템이 기본으로 Hold to Turn On, Hold to Turn Off 동작 힌트를 기본 켬/끔 값 텍스트의 경우와 유사하게 합성했습니다 값 텍스트를 사용자화했을 때 동작 힌트 구성에도 사용된 것입니다 시스템이 합성해낸 동작 힌트가 Hold for Running, Hold for Stopped입니다 이 힌트는 개선하면 좋을 것 같아 사용 사례에 맞게 맞춤화하겠습니다
동작 버튼 힌트 텍스트 맞춤화는 controlWidgetActionHint 한정자로 합니다 동사로 시작해야 하는 동작 힌트를 선택하겠습니다 제공되는 힌트는 특정 상태로 이동하는 동작입니다 따라서 타이머의 켜짐 상태에 대한 동작 힌트는 “Start”이고 타이머 시작에 대한 힌트는 ‘Hold to Start’입니다 꺼짐 상태의 동작 힌트가 “Stop”이므로 멈추기 위한 동작 힌트는 ‘Hold to Stop’입니다 타이머 기능에 아주 잘 어울리고 멋져 보이네요!
제어 기능이 제어 센터에 일시적인 상태를 표시하게 하려면 controlWidgetStatus 한정자를 사용하여 작업을 수행하면 됩니다 제어 기능에서 동작에 대한 추가 정보를 전달해야 하는 경우 예를 들어 동작 상태, 해당 상태의 유효 기간에 관한 상태를 추가하는 것을 고려하세요 상태 텍스트 사용은 조심스럽게 제어 기능이 아직 전달하지 않은 관련 정보에 대한 주의를 환기시킬 때만 사용해야 합니다
제어 항목 갤러리에서 제어 항목을 추가하면 지금은 제 앱의 이름인 Productivity라고 나옵니다
제어 기능의 앱 이름은 제어 기능의 기본 표시 이름입니다
제어 기능의 displayName을 “Productivity Timer”로 하겠습니다 각 제어 기능에 대해 해당 제어 기능의 작업에 맞는 displayName을 선택해야 합니다 마지막으로 설명도 추가해서 제어 기능을 구성할 때 표시되도록 만들어 보겠습니다 몇 단계만 거치면 제어 센터, 잠금 화면 또는 동작 버튼에 배치하고 모든 기기에서 동기화할 수 있는 생산성 타이머를 만들 수 있습니다 제어 기능은 이제 iOS 및 iPadOS 18에서 앱에 내장할 수 있는 강력한 기능으로 시스템 공간에서 앱의 주요 동작에 빠르게 액세스할 수 있게 합니다 오늘 설명한 한정자를 사용하여 제어 기능이 수행하는 작업에 맞게 제어 기능의 스타일을 조정하고 제어 기능에 어울리는 고유한 기호도 사용해 보세요
카메라로 콘텐츠를 캡처할 수 있는 제어 기능을 빌드하고 있다면 캡처 확장 기능 구축을 고려하고 도움이 될 세션인 ‘잠금 화면 카메라 캡처 경험을 멋지게 빌드하기’를 시청하세요
시청해 주셔서 감사합니다!
-
-
3:13 - Add the control to the Widget Bundle
@main struct ProductivityExtensionBundle: WidgetBundle { var body: some Widget { ChecklistWidget() TaskCounterWidget() TimerToggle() } }
-
3:29 - Complete the control
struct TimerToggle: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration( kind: "com.apple.Productivity.TimerToggle" ) { ControlWidgetToggle( "Work Timer", isOn: TimerManager.shared.isRunning, action: ToggleTimerIntent() ) { _ in Image(systemName: "hourglass.bottomhalf.filled") } } } }
-
4:41 - Specify different symbols when on and off
struct TimerToggle: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration( kind: "com.apple.Productivity.TimerToggle" ) { ControlWidgetToggle( "Work Timer", isOn: TimerManager.shared.isRunning, action: ToggleTimerIntent() ) { isOn in Image(systemName: isOn ? "hourglass" : "hourglass.bottomhalf.filled") } } } }
-
5:21 - Specify custom value text and add a custom tint color
struct TimerToggle: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration( kind: "com.apple.Productivity.TimerToggle" ) { ControlWidgetToggle( "Work Timer", isOn: TimerManager.shared.isRunning, action: ToggleTimerIntent() ) { isOn in Label(isOn ? "Running" : "Stopped", systemImage: isOn ? "hourglass" : "hourglass.bottomhalf.filled") } .tint(.purple) } } }
-
8:14 - Implement timer toggling
struct ToggleTimerIntent: SetValueIntent, LiveActivityIntent { static let title: LocalizedStringResource = "Productivity Timer" @Parameter(title: "Running") var value: Bool // The timer’s running state func perform() throws -> some IntentResult { TimerManager.shared.setTimerRunning(value) return .result() } }
-
8:54 - Refresh the control from within the app
func timerManager(_ manager: TimerManager, timerDidChange timer: ProductivityTimer) { ControlCenter.shared.reloadControls( ofKind: "com.apple.Productivity.TimerToggle" ) }
-
10:03 - Define a Value Provider
struct TimerValueProvider: ControlValueProvider { func currentValue() async throws -> Bool { try await TimerManager.shared.fetchRunningState() } let previewValue: Bool = false }
-
11:00 - Provide asynchronously fetched state with a Value Provider
struct TimerToggle: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration( kind: "com.apple.Productivity.TimerToggle", provider: TimerValueProvider() ) { isRunning in ControlWidgetToggle( "Work Timer", isOn: isRunning, action: ToggleTimerIntent() ) { isOn in Label(isOn ? "Running" : "Stopped", systemImage: isOn ? "hourglass" : "hourglass.bottomhalf.filled") } .tint(.purple) } } }
-
13:06 - Make the Value Provider configurable
struct ConfigurableTimerValueProvider: AppIntentControlValueProvider { func currentValue(configuration: SelectTimerIntent) async throws -> TimerState { let timer = configuration.timer let isRunning = try await TimerManager.shared.fetchTimerRunning(timer: timer) return TimerState(timer: timer, isRunning: isRunning) } func previewValue(configuration: SelectTimerIntent) -> TimerState { return TimerState(timer: configuration.timer, isRunning: false) } }
-
13:40 - Make the timer configurable
struct TimerToggle: ControlWidget { var body: some ControlWidgetConfiguration { AppIntentControlConfiguration( kind: "com.apple.Productivity.TimerToggle", provider: ConfigurableTimerValueProvider() ) { timerState in ControlWidgetToggle( timerState.timer.name, isOn: timerState.isRunning, action: ToggleTimerIntent(timer: timerState.timer) ) { isOn in Label(isOn ? "Running" : "Stopped", systemImage: isOn ? "hourglass" : "hourglass.bottomhalf.filled") } .tint(.purple) } } }
-
14:26 - Prompt for user configuration automatically
struct SomeControl: ControlWidget { var body: some ControlWidgetConfiguration { AppIntentControlConfiguration( // ... ) .promptsForUserConfiguration() } }
-
15:42 - Custom action hint -> hint treated as verb phrase
struct TimerToggle: ControlWidget { var body: some ControlWidgetConfiguration { AppIntentControlConfiguration( kind: "com.apple.Productivity.TimerToggle", provider: ConfigurableTimerValueProvider() ) { timerState in ControlWidgetToggle( timerState.timer.name, isOn: timerState.isRunning, action: ToggleTimerIntent(timer: timerState.timer) ) { isOn in Label(isOn ? "Running" : "Stopped", systemImage: isOn ? "hourglass" : "hourglass.bottomhalf.filled") .controlWidgetActionHint(isOn ? "Start" : "Stop") } .tint(.purple) } } }
-
16:56 - Specify a display name and add a description
struct TimerToggle: ControlWidget { var body: some ControlWidgetConfiguration { AppIntentControlConfiguration( kind: "com.apple.Productivity.TimerToggle", provider: ConfigurableTimerValueProvider() ) { timerState in ControlWidgetToggle( timerState.timer.name, isOn: timerState.isRunning, action: ToggleTimerIntent(timer: timerState.timer) ) { isOn in Label(isOn ? "Running" : "Stopped", systemImage: isOn ? "hourglass" : "hourglass.bottomhalf.filled") .controlWidgetActionHint(isOn ? "Start" : "Stop") } .tint(.purple) } .displayName("Productivity Timer") .description("Start and stop a productivity timer.") } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.