스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI용 StoreKit 알아보기
App Store 제품 메타데이터와 Xcode 미리보기를 사용해 단 몇 줄의 코드로 앱에 앱 내 구입 항목을 추가하는 방법을 알아보세요. StoreKit에서 제공하는 새로운 UI 구성 요소 컬렉션을 살펴보고, 상품을 쉽게 판촉하는 방법과 사용자가 정보를 바탕으로 결정을 내릴 수 있도록 구독을 제시하는 방법 등도 알아보세요.
리소스
관련 비디오
WWDC23
- App Store 서버 API의 새 기능
- App Store Connect의 새로운 기능
- StoreKit 2 및 Xcode 내 StoreKit Testing의 새로운 기능
- SwiftUI의 새로운 기능
WWDC22
WWDC21
WWDC20
-
다운로드
♪ ♪
'SwiftUI의 StoreKit 알아보기'에 오신 것을 환영합니다 저는 StoreKit 팀의 엔지니어 그레그입니다 앱 내 구입의 판촉에 관해 얘기해 보겠습니다 앱 내 구입의 판촉이란 여러분의 상품을 제시하고 고객에게 구입을 완료할 방법을 제공하는 것입니다
판촉은 판매하는 상품에서 얻은 데이터와 고객의 상태를 파악하는 것에서 시작됩니다 고객이 비소모성 상품을 이미 소유했는지 또는 서비스를 구독했는지가 그 예시죠
이러한 데이터를 결합해 고객에게 상품을 마케팅할 인터페이스를 구축하고 상품의 구입을 위한 상호작용을 제공할 수 있습니다 이 빨간 사각형은 인터페이스의 구축에 들이는 모든 노력을 간략화한 것입니다
인터페이스의 구축에는 다양한 국면과 여러 분야의 기술이 필요하죠 그런 다음 고객이 상품의 구입 여부를 선택합니다 앱은 구입 API를 사용해 이에 반응해야 하며 인터페이스에 구입 결과를 반영해야 합니다 앱에 앱 내 구입을 추가한 경험이 있다면 적절한 판촉이 중요한 걸 아실 겁니다 이 모든 단계를 간단하지만 더 뚜렷한 뷰로 추상화할 수 있다면 더 좋겠죠? 이 뷰를 통해 모든 공통 기능을 처리하고 매개 변수를 통해 앱을 앱답게 만드는 요소를 구성할 수 있습니다 StoreKit에서 판촉 UI의 구축에 사용할 수 있는 강력한 API들을 소개할 수 있어서 기쁘네요 Xcode 15에서 Storekit은 이제 SwiftUI 뷰 모음집을 제공해 선언형 앱 내 구입 UI를 구축하는 것을 도와줍니다 여러분이 원하는 판촉 경험을 선언하기만 하면 시스템이 배경에서 그 선언을 실행하죠
StoreView, ProductView SubscriptionStoreView는 판촉을 가장 빨리 준비하고 실행할 수 있는 새로운 뷰들입니다 이들은 App Store에서의 데이터 흐름을 추상화하고 앱 내 구입을 나타낼 시스템 제공 UI를 표시하죠 익숙한 SwiftUI API를 사용해 이 뷰들이 앱에서 통합되는 방식을 사용자 지정할 수도 있습니다
SwiftUI처럼 이 새로운 뷰들도 모든 플랫폼을 지원하기에 iPhone, iPad, Mac Apple Watch, Apple TV에서 앱 내 구입의 판촉이 훨씬 쉬워지죠
깃털 달린 친구들 한 무리가 제게 찾아와 'Backyard Birds'라는 새로운 게임에 앱 내 구입을 추가하게 도와달라고 부탁했는데요
StoreKit의 이 새로운 뷰들이 있으니 전 아무 문제 없다고 대답했죠
저와 함께 'Backyard Birds'에 완벽한 앱 내 구입 경험을 전달해 봅시다 자유롭게 샘플 프로젝트를 다운로드해 같이 작업해 보세요 Xcode Previews로 SwiftUI 뷰를 빠르게 반복할 건데요
다룰 부분이 정말 많기 때문에 제가 미리 StoreKit 구성 파일을 준비해 뒀습니다 여기엔 앱 내 구입의 메타데이터가 포함되어 있는데요 StoreKit에서 Xcode Previews를 사용할 때 꼭 필요한 것이죠
앱을 준비할 때 도움을 줄 훌륭한 세션이 있는데요 'StoreKit 테스팅의 새로운 점'과 'Xcode에서 StoreKit 테스팅 소개하기'가 그것입니다 바로 Xcode로 넘어가 보죠 우리는 'Backyard Birds'에서 영양 펠릿 같은 고급 새 먹이를 판매하려고 하는데요 이 먹이를 구입해 뒷마당에 두면 배고픈 새 방문객을 더 많이 끌어들일 수 있죠 이제 코드로 넘어가 Storekit을 활용해 이 상품을 판촉해 봅시다
우선 BirdFoodShop이라는 뷰를 생성해 새 먹이의 판촉에 활용할 건데요
제가 이 뷰를 구현할 파일을 미리 생성해 뒀죠 Storekit을 사용해 뷰를 구축하려면 Storekit과 SwiftUI를 파일 상단으로 가져와야 합니다
이제 여기에 쿼리를 선언해 새 먹이 데이터 모델을 가져옵니다 이러면 상점을 구축하는 데 도움이 되죠
제가 앱에 StoreView를 추가했는데 판촉 뷰를 준비하고 실행할 가장 빠른 방법이기 때문입니다 이제 StoreKit 구성 파일의 상품 식별자 컬렉션을 제공할 건데요 이는 birdFood 모델에서 가져올 수 있습니다
이렇게 선언하면 이제 판촉 뷰가 작동합니다 Storekit이 App Store에서 모든 상품 식별자를 로드해 UI에서 볼 수 있도록 제공하죠 표시 이름, 설명, 가격 등 App Store에서 제공하는 모든 것은 App Store Connect나 StoreKit 구성 파일에서 설정한 내용을 사용합니다 Storekit은 미세하지만 더 중요한 고려 사항도 처리하는데요 만료나 시스템 메모리 압력 전까지 데이터를 캐시하는 것이나 스크린 타임에서 앱 내 구입을 비활성화했는지를 확인하는 등이죠 조금 전 새 디자이너들이 각 새 먹이 상품의 장식 아이콘을 보냈는데요 이 아이콘들을 StoreView에 추가하려면 후행 뷰 빌더를 추가하고 아이콘을 표현할 SwiftUI 뷰를 전달하기만 하면 됩니다
뷰 빌더는 상품 값을 매개 변수로 사용하며 이를 통해 사용할 아이콘을 결정할 수 있습니다 저는 애셋 카탈로그에서 상품 ID를 받아와 적절한 아이콘을 찾아 줄 헬퍼 뷰를 생성했습니다
이것을 여기에 넣으면 미리 보기가 업데이트되어 각 상품의 아이콘이 나타나죠 StoreView는 상품 식별자와 아이콘으로 기능적이고 잘 디자인된 상점을 만들어 주어 쉽게 시작할 수 있도록 도와줍니다 StoreView의 강력한 기능 중 하나는 상품이 다른 플랫폼에도 자동으로 적용되기 때문에 iPad, Mac, Apple Watch의 상점도 멋지게 준비할 수 있다는 점입니다 Apple Watch의 상점을 미리 보게 Xcode에서 대상을 변경해 보죠
좋아 보이네요! Apple Watch에서도 새 먹이를 팔 수 있겠어요
여러분만의 상품 제공을 위해 상품을 정리해 두는 건 흔한 방식이죠
우리의 새 디자이너들이 열심히 노력해 새 먹이를 소개할 구도를 만들었습니다 이 구도는 가장 값이 좋은 상품을 눈에 띄도록 전시하고 다른 상품은 선반에 정리해 두는데요
StoreView로 완성할 수 있는 목록 스타일 레이아웃과는 다르지만 Storekit은 이것 역시 다룰 수 있죠
새로운 ProductView를 활용해 더 자세한 레이아웃이 가능합니다 사실 우리가 방금 살펴본 StoreView는 동일한 ProductView를 사용해 행을 생성하죠 새로운 상점을 위해 컨테이너를 선언하겠습니다
저는 주력 상품인 영양 펠릿 상자를 다른 상품들보다 눈에 띄게 전시하려고 하는데요 이를 위해 영양 펠릿 상자의 ID를 제공해 ProductView를 선언하겠습니다
StoreView처럼 후행 클로저를 추가해 장식 아이콘을 추가할 수 있죠 이전에 썼던 헬퍼 뷰를 다시 사용하겠습니다
다음은 다른 먹이 물품을 위해 아래쪽 섹션을 추가할 텐데요 우선 주력 상품의 뒤에 배경을 넣겠습니다
그리고 헤더와 만들어 둔 다른 헬퍼 뷰를 넣어 새 먹이를 선반에 배치해 보죠
이 선반 헬퍼 뷰 안에서 장식 아이콘과 함께 각 새 먹이 상품의 ProductView를 선언할 수 있습니다
이 상점을 매듭지으려면 마지막 하나가 남았는데요 우리는 이 영양 펠릿 상자를 고객 눈에 띄도록 표시하고 싶지만 새 디자이너들의 말로는 지금보다 더 좋게 보일 방법이 있다더군요 이 새들을 만족시키기 위해 productViewStyle API를 사용해 주력 상품을 위한 스타일을 설정할 수 있습니다 저는 확대 스타일을 선택해 상품을 확실하게 강조하겠습니다
겨우 몇 분 만에 StoreKit의 새로운 ProductView를 사용해 새 먹이를 위한 특화 상점을 구축했습니다
딱 하나 추가한 뷰 수정자 덕분에 확대 ProductView 스타일이 주력 상품을 눈에 띄게 표시할 수 있습니다
필요에 맞춰 선택할 수 있는 기본 스타일이 세 가지 있는데요 조밀 스타일은 좁은 공간에 더 많은 상품을 표시할 수 있죠 새 먹이 선반은 기본적으로 보통 스타일을 사용하고요 물론 눈에 띄는 전시를 위한 확대 스타일도 있죠
ProductView 인스턴스가 StoreView를 구성하기 때문에 동일한 productViewStyle 수정자로 StoreView의 스타일을 변경할 수 있습니다
사용자 지정 스타일을 생성해 ProductView와 StoreView에 사용할 수도 있죠 조금 있다가 세션 뒷부분에서 그 방법을 보여 드리겠습니다
지금까지 소모성 새 먹이를 앱 내 구입으로 제공할 방법을 ProductView로 구축했는데요 영업부 새들은 아직 부족하다면서 아주 열성적인 새 관찰자를 위한 구독 서비스인 'Backyard Birds Pass'를 제공할 방법을 요구했습니다 ProductView나 StoreView 대신 구독 UI의 구축에 사용할 수 있는 새로운 SubscriptionStoreView는 구독에 특화된 뷰입니다 Xcode로 돌아가 같이 구축해 보죠 시작할 수 있도록 StoreKit 구성에서 Backyard Birds Pass 구독 그룹을 생성하고 3단계의 서비스를 제공했습니다
이 그룹 ID를 기억해 두세요 조금 있다 필요하니까요
제가 미리 구독권 상점을 위한 새로운 파일을 하나 만들었는데요 이제 SubscriptionStoreView에 뛰어들어 봅시다
SubscriptionStoreView로 준비하고 실행할 가장 빠른 방법은 StoreKit 구성 파일이나 App Store Connect에서 얻은 그룹 ID를 제공하는 것입니다 제가 이미 그룹 ID를 환경에 추가했기에 접근을 위한 환경 속성을 선언하기만 하면 됩니다 그런 다음 그룹 ID를 제공해 SubscriptionStoreView를 선언하죠
StoreView 및 ProductView와 마찬가지로 SubscriptionStoreView 역시 데이터 흐름을 관리하고 여러 요금제 옵션을 뷰에 배치해 줍니다 거기다 기존 구독자의 상태와 고객이 입문자 혜택을 받을 수 있는지도 확인할 수 있죠 이런 자동화 형태도 멋져 보이지만 뷰의 룩 앤드 필이 'Backyard Birds'와 맞도록 사용할 수 있는 강력하고 새로운 API가 몇 개 있습니다 예를 들어 헤더의 마케팅 콘텐츠를 SwiftUI 뷰로 대체할 수 있죠 미리 구축한 마케팅 콘텐츠 뷰를 여기 넣어 두겠습니다
컨테이너 배경을 구독 상점에 추가해 시각적 흥미를 더할 수도 있습니다 새로운 SwiftUI containerBackground API로요
제가 어떻게 이걸 구독 상점의 전체 높이에 배치하고 하늘 그라디언트와 구름을 넣어 생성해 둔 뷰를 선언했는지에 주목하세요 이 모든 것을 하나로 묶기 위해 구독 상점의 스타일에 쓸 몇 가지 API를 사용할 수 있습니다 기본적으로 구독 상점은 구독 컨트롤과 전체 높이 사이에 재질 레이어를 추가하죠
배경 스타일 수정자를 사용해 구독 컨트롤의 배경을 깔끔하게 할 수 있습니다
subscriptionStoreButtonLabel로 구독 버튼에 쓸 여러 줄 레이아웃을 선택해 보죠
이제 구독 버튼에 가격과 '무료로 체험하기'가 포함된 것에 주목하세요
그런 다음에 추가한 subscriptionStorePickerItemBackground로 구독 옵션에 재질 효과를 선언하겠습니다
여길 보면 구독 요금제 옵션에 하늘 그라디언트가 비치죠
마지막으로 우리의 구독 서비스는 할인 코드가 있기 때문에 새로운 storeButton 수정자를 사용해 코드 사용 버튼이 보이도록 선언하겠습니다
이 수정자 하나로 고객에게 할인 코드 사용 시트를 열 수 있는 버튼을 제공한 거죠
이제 Backyard Birds의 나머지와 구독 뷰의 느낌이 같아졌습니다 이 새로운 뷰들은 앱 내 구입을 추가하는 데 필요한 노력을 크게 줄이지만 몇 가지 중요한 부분이 아직 남았습니다 첫 번째는 구입이 완료된 이후 콘텐츠를 실제로 해금하는 논리를 추가해야 합니다
두 번째로 사용자가 이미 구독 중인지 확인한 다음 SubscriptionStoreView를 제시하는 모든 컨트롤을 숨겨야 하죠
StoreKit 뷰는 자동으로 고객이 구독 중인지를 처리하지만 대부분의 경우 기존의 고객들에게 판촉 UI를 제시하지 않는 것이 최고의 경험을 주는 방법입니다
Storekit은 새롭게 도입된 API를 통해 이러한 중요한 기능을 콘텐츠를 판매하는 것만큼 쉽고 재미있게 구현하도록 도와줍니다 이러한 API를 시작하기 전에 비즈니스 로직을 이미 구현하거나 적어도 어느 정도의 발판을 마련하고 싶으시겠죠 업데이트된 거래 사항을 처리하면서 서버와 협력하고 소모성 상품의 권리를 추적하고 UI 코드와 다른 것들에 적합한 데이터 모델을 생성하는 것을 고려해야 합니다 'StoreKit 2 알아보기'와 'App Store 서버 API의 새로운 점'을 확인해 비즈니스 로직을 구현하는 방법을 더 자세히 알아보시길 바랍니다
제가 우리의 새 비즈니스 로직을 BirdBrain이라는 행위자에 미리 구현해 뒀습니다 이를 참조하는 것을 곧 보여 드리죠
새 관찰자가 구입한 소모성 새 먹이에 접근할 수 있도록 해 보겠습니다 StoreKit 뷰에서는 발생한 구입을 간단히 처리할 수 있는데요 onInAppPurchaseCompletion으로 뷰를 수정하고 구입이 완료될 때마다 호출할 함수를 제공하면 되죠 이 메서드를 사용해 어떤 뷰든 수정할 수 있으며 하위 StoreKit 뷰에서 구입이 완료될 때마다 해당 뷰가 호출될 겁니다 BirdFoodShop에 이 수정자를 추가해 보죠
이 수정자는 구입한 상품과 구입 결과 즉 구입이 성공했는지의 여부를 제공합니다 BirdBrain 행위자에 성공 결과를 전송해 처리할 수 있도록 이를 구현해 봅시다
이 수정자를 추가하는 것으로 이제 구입한 소모성 새 먹이를 해금할 수 있습니다 시뮬레이터에서 확인해 보죠
뒷마당을 선택해 물품을 탭합니다
그런 다음 영양 펠릿을 구입하겠습니다
시트가 닫히면 물품 창고에 영양 펠릿 5개가 생긴 것을 확인할 수 있죠
이제 영양 펠릿 하나를 마당에 놓은 뒤 배고픈 새들이 모이는 것을 지켜보기만 하면 됩니다
onInAppPurchaseCompletion 외에도 StoreKit 뷰에서 이벤트를 처리할 때 사용하는 관련된 뷰 수정자가 몇 가지 있습니다
onInAppPurchaseStart를 사용하면 트리거된 구입 버튼을 구입이 진행되기 전에 처리할 수 있습니다 구입이 진행되는 중에 투명화 컨트롤 같은 UI 구성 요소를 업데이트할 때 유용하죠 여기서 제공하는 함수는 구입할 상품을 매개 변수로 받습니다
이러한 수정자는 사용할 때 하위 ProductView, StoreView SubscriptionStoreView 인스턴스에서 발생하는 이벤트를 처리한다는 점을 알아 두는 게 중요합니다 여러 수정자를 추가하면 각 이벤트의 모든 작업이 실행되죠 이 수정자들은 어디까지나 선택 사항임을 염두에 두세요 기본적으로 StoreKit 뷰에서 성공적인 거래 사항은 Transaction.updates 시퀀스에서 방출하는데 onInAppPurchaseCompletion을 추가하는 것으로 결과를 직접 처리할 수도 있습니다
위 수정자에 nil을 전달하면 기본 동작으로 돌아가고요
이제 Backyard Birds Pass 구독의 처리 방법을 얘기해 보겠습니다 새로운 뷰 API 외에도 Storekit은 SwiftUI에서 데이터 종속성을 선언하는 새로운 뷰 수정자를 제공합니다 우선 subscriptionStatusTask를 다뤄 볼 텐데요 이를 사용하면 구독권의 해금을 손쉽게 처리할 수 있죠 구독에 종속된 뷰에는 subscriptionStatusTask 수정자를 추가할 수 있습니다 Backyard Grid에서 시작해 볼 텐데요 구독 제안 시트를 여는 버튼이 여기서 나타나기 때문이죠
subscriptionStatusTask 수정자는 종속된 구독의 그룹 ID를 사용합니다
이 그룹 ID는 앞에서 SubscriptionStoreView를 선언할 때 사용한 것입니다 이제 Backyard Grid가 나타날 때마다 백그라운드 작업이 구독 상태를 로드하고 작업이 완료될 때 제공한 함수를 호출합니다
이 API를 실행하는 가장 좋은 방법은 상태를 비즈니스 로직 이 경우에는 BirdBrain 행위자에 전달하는 것인데요 그러면 행위자는 데이터를 처리하고 UI 코드에서 더 쉽게 작업할 모델 유형을 반환할 것입니다
구독권 상태 열거형을 만들었으니 이를 할당할 상태 속성을 만들어 보겠습니다
그런 다음 현재 구독 중이 아닌 경우에만 구독 제안 카드가 나타나게 할 겁니다
이 간결한 추가 덕분에 구독하지 않은 새 관찰자에게만 구독 제안 카드가 나타나죠 상태가 바뀔 때 Storekit이 함수를 호출하므로 뷰는 항상 최신 정보를 반영합니다
Backyard Birds Pass 콘텐츠를 앱에서 해금할 때도 같은 방식을 사용할 수 있으며 onInAppPurchaseCompletion 수정자를 사용해 구독에 성공하면 구독권 상점 시트를 자동으로 닫게 할 수 있습니다 제가 이 부분을 미리 완성했으므로 iPhone 시뮬레이터에서 앱을 실행해 테스트해 보겠습니다
'확인해 보기'를 탭하고 '무료로 체험하기'를 누릅니다
나타난 결제 시트에서 '구독하기'를 탭하면 알림이 닫힙니다
제안 시트가 자동으로 닫히고 제안 카드가 숨겨지는 것에 주목해 주세요 상태가 바뀔 때마다 구독 상태 작업이 함수를 호출하기 때문에 앱의 UI를 항상 최신 상태로 유지할 수 있습니다
이 주제와 관련해서 앱이 비소모성 상품이나 비갱신 구독을 제공할 때 subscriptionStatusTask와 유사한 새로운 API를 사용해 권한 확인을 쉽게 할 수 있습니다 currentEntitlementTask 수정자를 사용하면 상품 ID의 현재 권한에 종속된 뷰를 선언할 수 있으며 시스템은 비동기적으로 현재 권한을 로드하고 현재 권한이 변경될 때마다 함수를 호출합니다
subscriptionStatusTask와 currentEntitlementTask에 제공하는 함수는 권한 상태를 매개 변수로 받습니다 해당 방법은 다음 경우를 세밀하게 처리할 수 있습니다 권한이 계속 로드되는 경우 로드에 실패한 경우 성공적으로 권한을 로드한 경우죠 지금까지 이 새로운 StoreKit 뷰들이 어떻게 앱 내 구입의 통합을 간소화하는지를 다뤘는데요 이제 조금 더 깊이 들어가 SwiftUI를 위한 새로운 StoreKit API를 활용해 이 뷰들을 더 발전시키는 방법을 보여 드리겠습니다
우선 ProductView와 StoreView에서 아이콘을 설정하는 더 많은 방법을 살펴볼 겁니다 그리고 상품 뷰의 스타일 방법을 자세히 짚을 거고요 그런 다음 StoreView와 SubscriptionStoreView에서 공통 기능을 갖는 버튼을 추가하는 방법을 다루겠습니다 마지막으로 Subscription Store View를 브랜드의 룩 앤드 필과 맞도록 사용할 수 있는 여러 새로운 API를 짚어 보죠 장식 아이콘부터 시작하겠습니다 아이콘을 제공하면 상품을 로드할 때 모든 표준 상품 뷰 스타일이 자리 표시자 아이콘을 보여 줍니다 이 왼쪽에 보이는 것처럼요 가끔은 자동화 아이콘이 원하는 실제 아이콘과 정확히 들어맞지 않을 때도 있습니다 iPhone의 자동화 자리 표시자는 정사각형이지만 우리는 새 먹이 상품에 원 아이콘을 사용하려는 것처럼요
자리 표시자에 사용하려는 아이콘과 함께 두 번째 후행 클로저를 ProductView에 추가하는 것으로 이런 외형 문제를 쉽게 개선할 수 있습니다 이 경우에 저는 자리 표시자에 원을 제공했죠
App Store Connect에서 설정한 App Store 프로모션 이미지를 ProductView에서 SwiftUI 뷰 대신 사용할 수 있습니다 prefersPromotionalIcon 매개 변수를 참으로 설정하면 되죠
여전히 SwiftUI 뷰를 대신 제공할 수도 있지만 상품에 프로모션 아이콘이 있는 한 해당 뷰는 무시될 겁니다
'Xcode 내 StoreKit 2 및 StoreKit 테스팅의 새로운 점'과 'App Store Connect의 새로운 점'에서 자세히 알아보세요
App Store의 프로모션 아이콘을 사용하지 않을 경우에도 SwiftUI에서 선언된 아이콘 대신 멋진 앱 내 구입 아이콘 형식을 사용할 수 있습니다
아이콘으로 제공하는 뷰에 이 수정자를 추가하면 해당 뷰에 이런 경계선이 추가되죠 지금까지 상품 뷰에서의 아이콘과 관련된 내용이었습니다 상점 뷰 아이콘에서도 동일하게 쓸 수 있는 대응 API가 있다는 점을 염두에 두세요
이제 상품 뷰의 스타일을 얘기해 보겠습니다
앞부분에서 상품 뷰 스타일을 사용자 지정할 수 있다고 했죠 이제 그 방법을 보여 드릴 차례입니다
상품 뷰의 외형 레이아웃 동작, 상호작용은 사용하는 스타일이 전부 정의하는데요 마음에 들며 알맞은 표준 스타일을 찾을 수 없는 경우 사용자 지정 상품 뷰 스타일을 생성할 수 있습니다
첫 번째 경우는 표준 스타일을 바탕으로 사용자 지정 스타일을 생성하는 것으로 이 경우에는 처음부터 전부 만들 필요가 없죠 예를 들어 상품 뷰에서 로드 중일 때 표준 자리 표시자 외형 대신 진행 스피너를 표시하고 싶을 경우가 있을 텐데요
사용자 지정 스타일을 생성하는 첫 단계는 ProductViewStyle 프로토콜을 준수하는 유형을 생성하는 겁니다
프로토콜을 구현할 때 필요한 유일한 요구 사항은 이 makeBody 메서드죠 makeBody 메서드에 전달되는 구성 값에는 완벽한 상품 뷰의 선언에 필요한 모든 속성이 포함되어 있습니다 상품 로딩의 여러 상태를 다루는 상태 열거형이 그중 하나죠 로딩 외형을 사용자 지정하려면 로딩 상태에 ProgressView를 선언하기만 하면 됩니다
그리고 ProductView 인스턴스에 구성을 전달하는 것만으로도 표준 ProductView 동작을 대체할 수 있죠
표준 스타일과 동일한 방식으로 사용자 지정 스타일을 적용할 때도 productViewStyle 수정자를 전달하면 됩니다
물론 사용자 지정 스타일과 함께 표준 스타일을 구성할 필요는 없죠
그 어떤 다른 뷰에서도 makeBody 메서드로 여러분의 스타일을 정의할 수 있습니다 작업 상태가 성공일 경우 뷰가 표현하는 상품 값에 접근할 수 있다는 뜻이죠 이는 앱이 StoreKit 2를 사용할 경우 이미 작업 중인 상품 값과 동일합니다 상품의 모든 속성을 사용해 뷰를 생성할 수 있습니다
이 구성에서도 마찬가지로 장식 아이콘에 접근할 수 있죠
구입 버튼을 추가할 때 상품 값이 아닌 구성 값에 구매 메서드를 사용해야 하는 것을 꼭 기억해 두세요 구성에 있는 해당 메서드를 사용하면 기본 구입 옵션을 추가해 결제 확인 시트가 상품 뷰 근처에 표시되도록 할 수 있으며 onInAppPurchaseCompletion 같은 반응 수정자도 트리거할 수 있죠
사용자 지정 스타일을 처음부터 만들었을 때 해당 스타일을 사용하는 상품 뷰의 외형 및 동작은 스타일을 구축할 때 사용한 뷰와 일치한다는 것을 기억하세요
사용자 지정 스타일을 생성하는 것은 App Store 데이터 흐름과 같은 상품 뷰의 모든 인프라를 활용하는 동시에 원하는 외형과 동작을 자유롭게 선언하는 좋은 방법이죠
로드 중에는 새 먹이 상점에서 구축한 UI에 각 상품이 자리 표시자 형태로 나타나는데요 어떻게 하면 오른쪽처럼 로딩 스피너가 나올 수 있을까요?
그 답은 상태 끌어올리기입니다 자세히 설명해 드리죠
이 다이어그램은 이전에 구축한 BirdFoodShop의 계층 구조를 의미합니다 BirdFoodShop의 하위에는 여러 ProductView가 있는데요 상품 ID로 ProductView를 초기화하면 각 뷰는 내부적으로 상품의 상태를 유지합니다 로딩 작업은 비동기적으로 수행되기 때문이죠 상품을 로드하는 동안 상위 BirdFoodShop이 다른 외형을 표시하는 효과를 생성하고 싶다면 상위 BirdFoodShop로 상태를 끌어올려야 합니다 상위 BirdFoodShop이 상품 상태를 관리하는 동안 데이터를 로드하는 중에 자유롭게 외형을 바꿀 수 있으며 상품 ID 대신 사전 로드한 상품 값을 사용해 ProductView 인스턴스를 생성합니다 지금까지는 상품 ID로 상품 뷰를 생성하는 방법을 다루었는데요 미리 로드한 상품 값을 ProductView에 전달할 수 있다는 것도 중요합니다 이렇게 하면 상품 뷰는 로딩을 건너뛰고 직접 판촉 뷰를 배치하죠
이렇게 생각하실 수도 있겠군요 다 좋은데 그걸 실현하려면 직접 상품 요청과 캐시 로직을 작성해야 하는 거 아니냐고요
여러분에게는 다행히도 저희가 뷰 수정자로 사용할 수 있도록 StoreKit 뷰의 내부를 공개했기에 상품 ID의 메타데이터에 종속된 모든 뷰를 선언할 수 있습니다 Storekit은 상품을 로드하고 캐시하며 최신 상태로 유지하는 작업을 처리할 거고요 이를 위해 사용할 것은 새로운 storeProductsTask 수정자뿐이죠 앞에서 다룬 subscriptionStatusTask처럼 종속된 뷰에 상품 ID 컬렉션을 전달할 수 있습니다
그러면 비동기 작업의 상태 처리에 사용할 상태 값을 얻을 수 있죠 왠지 상당히 익숙할 텐데요 사용자 지정 ProductViewStyle의 구현 방법을 방금 봤기 때문이죠
여기서 로드 중인 로딩 뷰가 나타나고요
상품이 사용 불가능일 때는 새로운 ContentUnavailableView를 쓰고
사전 로드된 상품 값으로는 BirdFoodShop을 직접 표시합니다 정말 간단하죠
간단한 것은 더 있습니다 앱 내 구입 판촉 UI에 내장된 유용한 공통 행위가 여럿 있는데요 SubscriptionStoreView와 StoreView를 사용하면 이러한 공통 행위를 위한 보조 버튼의 추가가 정말 쉬워지죠
여기서 말한 보조 버튼은 뷰의 주요 목적을 지원하는 행위를 수행하는 버튼을 뜻합니다 예를 들어 나가기 버튼과 코드 사용 버튼이 구독을 보조하는 버튼이죠
storeButton 수정자를 사용해 코드 사용 버튼을 추가하는 방법은 처음에 이 시트를 구축할 때 살펴봤습니다 이 뷰 수정자를 더 자세하게 살펴보죠
이 두 매개 변수에 전달할 수 있는 값이 몇 개 있는데 첫 번째 매개 변수는 가시성을 선택할 수 있습니다 모든 버튼의 기본 설정인 자동화는 조건에 따라 Storekit이 버튼의 가시성을 선택할 수 있게 합니다 버튼을 계속 보이게 하거나 숨기도록 명시할 수도 있죠
다음 매개 변수는 가시성을 구성할 버튼 종류를 선택할 수 있습니다
나가기 버튼은 뷰를 닫는 데 쓰이는 플랫폼에 적합한 버튼을 표시합니다 StoreView, SubscriptionStoreView 모두에 작동하는 버튼이죠
나가기 버튼의 표시는 뷰가 제시될 때마다 자동으로 동작합니다
오른쪽은 구독 상점 뷰가 시트로 제시된 것으로 나가기 버튼이 자동으로 상단 우측에 표시되죠 왼쪽에는 뷰가 시트로 제시되지 않았기에 나가기 버튼이 없습니다 물론 이 동작을 재정의해 제시되는 나가기 버튼을 숨기도록 할 수도 있습니다 여러분이 지정한 것으로 나가기 버튼을 대체할 경우에만 이를 실행하는 게 좋다는 것을 염두에 두세요 제시 창을 닫을 수 있는 명확한 버튼을 판촉 UI과 함께 제공하는 것이 좋습니다
나가기 버튼처럼 상점 뷰와 구독 상점 뷰에는 구입 항목 복원 버튼도 있습니다
구입 항목 복원 버튼은 기본적으로 항상 숨겨져 있는데요 storeButton 수정자로 판촉 UI에서 표시되게 할 수도 있습니다
다음 세 버튼은 SubscriptionStoreView 전용입니다
코드 입력하기 버튼은 앞에서 이미 얘기했죠 다음은 로그인 버튼입니다 App Store 외부에서 구독할 수 있게 하려면 기존 구독자가 자신의 구독 내용에 접근하도록 로그인 버튼을 표시하는 것이 좋습니다 로그인 버튼에서 중요한 점은 새로운 수정자인 subscriptionStoreSignInAction으로 로그인 행위를 선언해야 한다는 것입니다 로그인 행위를 설정했다면 로그인 버튼이 자동으로 나타납니다
subscriptionStoreSignInAction으로 선언한 기능을 로그인 버튼이 호출하기 때문에 이를 신호로 사용해 로그인 흐름을 실행할 수 있습니다
마지막 버튼의 용도는 정책 확인입니다 이용 약관 및 개인정보 보호 정책 링크를 구독 제안과 함께 표시할 때가 있죠 이때 SubscriptionStoreView로 이를 쉽게 수행할 수 있습니다
일반적으로 정책 버튼은 숨겨져 있는 게 기본인데요 storeButton 수정자로 보이게 하면 iOS와 Mac에서 구독 컨트롤 위에 표시됩니다 컨테이너 배경 뒤에 이 버튼이 표시되기에 기본 스타일이 배경과 구분되지 않을 수 있습니다 subscriptionStorePolicyForegroundStyle을 사용하면 정책 버튼이 배경과 구분되도록 형태 스타일을 설정할 수 있습니다
storeButton 수정자로 보조 버튼을 구성하면 간단한 선언으로도 판촉 UI에 강력한 기능을 추가할 수 있습니다 세션 앞부분에서 Backyard Birds의 룩 앤드 필에 맞도록 구독 상점 뷰의 스타일을 구성했는데요 이제 이 스타일 API를 자세히 살펴보겠습니다
먼저 컨트롤 스타일을 선택하는 방법을 보죠 SubscriptionStoreView는 판촉하는 구독 종류에 따라 컨트롤 스타일을 자동으로 선택합니다
새로운 수정자인 subscriptionStoreControlStyle로 구독 요금제에 맞는 컨트롤 스타일을 선택할 수 있죠 가령 요금제를 자동화 피커로 표시하는 대신 버튼으로 각각을 나타낼 수 있습니다
이제 여러 컨트롤 스타일을 얘기해 보죠
특정 스타일을 기술하지 않았다면 구독 상점 뷰가 자동으로 컨트롤을 지정합니다 iPhone에서는 자동으로 피커 컨트롤이 여러 구독 요금제를 표시하죠
명시적으로 피커 컨트롤을 선택할 수도 있습니다
iOS과 Mac에는 강조 피커 컨트롤이 있는데 그림자와 선택 고리로 구독 요금제를 강조해서 표시할 수 있습니다
마지막으로 각 구독 요금제를 피커 컨트롤 대신 버튼으로 표시할 수도 있습니다
구독 버튼과 관련해서 버튼 레이블을 사용자 지정할 수 있는 새로운 API가 있습니다
SubscriptionStoreView는 구독 버튼을 표시할 때 행위 구문과 가격 정보를 포함하는 캡션을 버튼 위에 놓는 것이 기본 설정입니다
subscriptionStoreButtonLabel 수정자를 추가해 버튼 레이블을 여러 줄로 변경할 수 있는데 이렇게 하면 가격 텍스트가 별도의 캡션으로 분리되는 대신 버튼 레이블에 포함됩니다
버튼 레이블의 레이아웃 외에도 내용 역시 사용자 지정할 수도 있습니다
예를 들어 행위 구문 대신 선택한 구독의 표시 이름을 보일 수 있죠
지금 보이는 것처럼 구성 요소를 함께 연결해 레이아웃과 내용으로 버튼 레이블 값을 구성할 수도 있습니다
버튼 컨트롤은 피커 컨트롤과 동일한 구독 버튼으로 구성되어 있기 때문에 동일한 수정자를 사용해 사용자 지정할 수 있습니다
예를 들어 레이블에 가격만 보이도록 할 수 있죠 동일한 서비스를 다른 요금제로 제공할 경우에 유용한 방법입니다
여러 구독 요금제는 App Store Connect에서 설정한 표시 이름과 설명을 사용해 컨트롤을 구축합니다 더 흥미로운 컨트롤을 위해 요금제마다 다른 장식 뷰를 추가할 수도 있죠
subscriptionStoreControlIcon 수정자를 장식 상점에 추가하는 것으로 장식 뷰를 추가할 수 있습니다
이 수정자는 뷰 빌더를 사용하는데요
뷰 빌더에 상품 값과 SubscriptionInfo 값을 모두 제공하죠 이 매개 변수로 요금제마다 다른 뷰를 제공할 수 있습니다
구독 요금제의 버튼 컨트롤 스타일에 아이콘을 사용할 수도 있죠 이제 구독 상점 뷰에 배경 콘텐츠를 추가하는 것을 더 자세히 살펴보겠습니다 앞부분을 요약하면 containerBackground 수정자로 판촉 콘텐츠를 수정해 컨테이너 배경을 구독 상점에 추가할 수 있었죠
이 경우 배경에 강조색 그라디언트를 제공해 구독 상점에 배치할 수 있습니다
새로운 containerBackground API를 더 자세히 알고 싶으시면 'SwiftUI의 새로운 점'을 참고하세요
구독 상점에서 배경의 배치 방법에는 여러 종류가 있습니다
구독 상점 배치를 사용하면 조건에 따라 자동으로 배치합니다
iOS와 Mac에서는 구독 상점의 헤더에 배경을 명시적으로 기술할 수 있죠 이 배치는 판촉 콘텐츠 뒤에 놓입니다
전체 높이 배치도 있는데요 구독 상점 뷰의 전체 높이 뒤 배경에 놓이는 것입니다
세션 앞부분에서 Backyard Birds Pass 얻기 시트를 제시하지 않는 선에서 구독 상태 작업 등의 API를 사용하는 방법을 의논했죠 하지만 기존 구독자에게 구독 상점 뷰를 보여야 할 때가 있습니다 바로 프리미엄 요금제로 업그레이드하도록 유도할 때죠
현재 구독자가 구독하는 서비스가 프리미엄 요금제보다 낮은 것을 감지하면 visibleRelationships 매개 변수에 upgrade를 전달해 업그레이드 시트를 제시할 수 있습니다 이는 원하는 모든 구독 조합에서 가능하며 현재 구독 중인 사용자에게만 영향을 미칩니다
더 효과적인 제안을 위해 프리미엄 요금제의 혜택을 설명하도록 마케팅 콘텐츠에 다른 뷰를 제공할 수도 있죠 subscriptionStatusTask를 사용해 구독자의 서비스 레벨을 추적하고 이 정보로 고객에게 어떤 제안을 제시할지 정할 수 있습니다
오늘 제가 다룰 내용은 여기까지입니다 앱에 앱 내 구입을 추가할 때 StoreView를 선언해 빠르게 설정하고 시작하세요
사용자 지정 레이아웃을 윈한다면 ProductView를 사용하시고요 구독 서비스에서는 SubscriptionStoreView를 선언해 매력적인 제안을 구축할 수 있습니다 그리고 다음 단계로 넘어갈 준비가 되었다면 새로운 뷰 수정자와 다른 API를 사용해 자신의 것으로 만들어 보시길 바랍니다
Storekit과 SwiftUI에 대해 더 알아보고 싶으시면 'Xcode 내 StoreKit 2 및 StoreKit 테스팅의 새로운 기능'과 'SwiftUI의 새로운 기능'을 참고하세요
SwiftUI의 새로운 StoreKit API를 함께 알아봐 주셔서 감사합니다 즐겁게 코딩하세요! ♪ ♪
-
-
3:35 - Setting up the bird food shop view
import SwiftUI struct BirdFoodShop: View { var body: some View { Text("Hello, world!") } }
-
3:42 - Import StoreKit to use the new merchandising views with SwiftUI
import SwiftUI import StoreKit struct BirdFoodShop: View { var body: some View { Text("Hello, world!") } }
-
3:51 - Declaring a query to access the bird food data model
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { Text("Hello, world!") } }
-
4:18 - Meet store view
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { StoreView(ids: birdFood.productIDs) } }
-
4:51 - Adding decorative icons to the store view
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { StoreView(ids: birdFood.productIDs) { product in BirdFoodProductIcon(productID: product.id) } } }
-
6:38 - Creating a container for a custom store layout
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { } } .scrollClipDisabled() } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
6:47 - Meet product view
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) } } .scrollClipDisabled() } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
7:03 - Adding a decorative icon to the product view
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } } } .scrollClipDisabled() } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
7:17 - Adding more containers to layout product views
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } .padding() .background(.background.secondary, in: .rect(cornerRadius: 20)) } } .scrollClipDisabled() Text("Other Bird Food") .font(.title3.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) ForEach(birdFood.premiumBirdFood) { birdFood in BirdFoodShopShelf(title: birdFood.name) { } } } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
7:36 - Declaring product views for the remaining products
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } .padding() .background(.background.secondary, in: .rect(cornerRadius: 20)) } } .scrollClipDisabled() Text("Other Bird Food") .font(.title3.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) ForEach(birdFood.premiumBirdFood) { birdFood in BirdFoodShopShelf(title: birdFood.name) { ForEach(birdFood.orderedProducts) { product in ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } } } } } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
7:50 - Choosing a product view style
import SwiftUI import StoreKit struct BirdFoodShop: View { @Query var birdFood: [BirdFood] var body: some View { ScrollView { VStack(spacing: 10) { if let (birdFood, product) = birdFood.bestValue { ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } .padding() .background(.background.secondary, in: .rect(cornerRadius: 20)) .padding() .productViewStyle(.large) } } .scrollClipDisabled() Text("Other Bird Food") .font(.title3.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) ForEach(birdFood.premiumBirdFood) { birdFood in BirdFoodShopShelf(title: birdFood.name) { ForEach(birdFood.orderedProducts) { product in ProductView(id: product.id) { BirdFoodProductIcon( birdFood: birdFood, quantity: product.quantity ) } } } } } .contentMargins(.horizontal, 20, for: .scrollContent) .scrollIndicators(.hidden) .frame(maxWidth: .infinity) .background(.background.secondary) } }
-
8:25 - Styling the store view
StoreView(ids: birdFood.productIDs) { product in BirdFoodShopIcon(productID: product.id) } .productViewStyle(.compact)
-
9:53 - Setting up the Backyard Birds pass shop
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { var body: some View { Text("Hello, world!") } }
-
9:57 - Meet subscription store view
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) } }
-
10:38 - Customizing the subscription store view's marketing content
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() } } }
-
10:57 - Declaring a full height container background
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } } }
-
11:21 - Configuring the control background style
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .backgroundStyle(.clear) } }
-
11:44 - Choosing a subscribe button label layout
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .backgroundStyle(.clear) .subscriptionStoreButtonLabel(.multiline) } }
-
12:01 - Choosing a subscription store picker item background
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .backgroundStyle(.clear) .subscriptionStoreButtonLabel(.multiline) .subscriptionStorePicketItemBackground(.thinMaterial) } }
-
12:20 - Declaring a redeem code button
import SwiftUI import StoreKit struct BackyardBirdsPassShop: View { @Environment(\.shopIDs.pass) var passGroupID var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .lightMarketingContentStyle() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .backgroundStyle(.clear) .subscriptionStoreButtonLabel(.multiline) .subscriptionStorePicketItemBackground(.thinMaterial) .storeButton(.visible, for: .redeemCode) } }
-
14:10 - Reacting to completed purchases from descendant views
BirdFoodShop() .onInAppPurchaseCompletion { (product: Product, result: Result<Product.PurchaseResult, Error>) in if case .success(.success(let transaction)) = result { await BirdBrain.shared.process(transaction: transaction) dismiss() } }
-
15:43 - Reacting to in-app purchases starting
BirdFoodShop() .onInAppPurchaseStart { (product: Product) in self.isPurchasing = true }
-
16:57 - Declaring a subscription status dependency
subscriptionStatusTask(for: passGroupID) { taskState in if let statuses = taskState.value { passStatus = await BirdBrain.shared.status(for: statuses) } }
-
19:37 - Unlocking non-consumables
currentEntitlementTask(for: "com.example.id") { state in self.isPurchased = BirdBrain.shared.isPurchased( for: state.transaction ) }
-
20:52 - Declaring placeholder icons
ProductView(id: ids.nutritionPelletBox) { BoxOfNutritionPelletsIcon() } placeholderIcon: { Circle() }
-
21:25 - Using the promotional icon
ProductView( id: ids.nutritionPelletBox, prefersPromotionalIcon: true ) { BoxOfNutritionPelletsIcon() }
-
21:56 - Using the promotional icon border
ProductView(id: ids.nutritionPelletBox) { BoxOfNutritionPelletsIcon() .productIconBorder() }
-
23:02 - Composing standard styles to create custom styles
struct SpinnerWhenLoadingStyle: ProductViewStyle { func makeBody(configuration: Configuration) -> some View { switch configuration.state { case .loading: ProgressView() .progressViewStyle(.circular) default: ProductView(configuration) } } }
-
23:44 - Applying custom styles to the product view
ProductView(id: ids.nutritionPelletBox) { BoxOfNutritionPelletsIcon() } .productViewStyle(SpinnerWhenLoadingStyle())
-
23:58 - Declaring custom styles
struct BackyardBirdsStyle: ProductViewStyle { func makeBody(configuration: Configuration) -> some View { switch configuration.state { case .loading: // Handle loading state here case .failure(let error): // Handle failure state here case .unavailable: // Handle unavailabiltity here case .success(let product): HStack(spacing: 12) { configuration.icon VStack(alignment: .leading, spacing: 10) { Text(product.displayName) Button(product.displayPrice) { configuration.purchase() } .bold() } } .backyardBirdsProductBackground() } } }
-
26:44 - Declaring a dependency on products
@State var productsState: Product.CollectionTaskState = .loading var body: some View { ZStack { switch productsState { case .loading: BirdFoodShopLoadingView() case .failed(let error): ContentUnavailableView(/* ... */) case .success(let products, let unavailableIDs): if products.isEmpty { ContentUnavailableView(/* ... */) } else { BirdFoodShop(products: products) } } } .storeProductsTask(for: productIDs) { state in self.productsState = state } }
-
27:54 - Configuring the visibility of auxiliary buttons
SubscriptionStoreView(groupID: passGroupID) { // ... } .storeButton(.visible, for: .redeemCode)
-
29:56 - Adding a sign in action
@State var presentingSignInSheet = false var body: some View { SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreSignInAction { presentingSignInSheet = true } .sheet(isPresented: $presentingSignInSheet) { SignInToBirdAccountView() } }
-
30:32 - Displaying policies from the App Store metadata
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStorePolicyForegroundStyle(.white) .storeButton(.visible, for: .policies)
-
31:22 - Choosing a control style
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreControlStyle(.buttons)
-
32:28 - Declaring the layout of the subscribe button label
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreButtonLabel(.multiline)
-
32:51 - Declaring the content of the subscribe button label
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreButtonLabel(.displayName)
-
33:04 - Declaring the layout and content of the subscribe button label
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreButtonLabel(.multiline.displayName)
-
33:44 - Decorating subscription plans
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreControlIcon { subscription, info in Group { let status = PassStatus( levelOfService: info.groupLevel ) switch status { case .premium: Image(systemName: "bird") case .family: Image(systemName: "person.3.sequence") default: Image(systemName: "wallet.pass") } } .foregroundStyle(.tint) .symbolVariant(.fill) }
-
34:07 - Decorating subscription plans with the button control style
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } } .subscriptionStoreControlIcon { subscription, info in Group { let status = PassStatus( levelOfService: info.groupLevel ) switch status { case .premium: Image(systemName: "bird") case .family: Image(systemName: "person.3.sequence") default: Image(systemName: "wallet.pass") } } .symbolVariant(.fill) } .foregroundStyle(.white) .subscriptionStoreControlStyle(.buttons)
-
34:14 - Adding a container background
SubscriptionStoreView(groupID: passGroupID) { PassMarketingContent() .containerBackground( .accent.gradient, for: .subscriptionStore ) }
-
35:30 - Presenting upgrade offers
SubscriptionStoreView( groupID: passGroupID, visibleRelationships: .upgrade ) { PremiumMarketingContent() .containerBackground(for: .subscriptionStoreFullHeight) { SkyBackground() } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.