스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
Shared Space에 맞게 iPad와 iPhone 앱 강화하기
이제 iPad와 iPhone 앱을 Shared Space에 맞게 강화할 수 있습니다. visionOS에서 멋진 느낌을 내도록 경험을 최적화할 방법을 소개하고 iPad를 위해 디자인된 앱의 상호 작용과 시각 처리, 미디어를 살펴봅니다.
챕터
- 1:00 - Interaction
- 7:55 - Visuals
- 8:57 - Media
리소스
관련 비디오
WWDC23
WWDC20
-
다운로드
♪ 감미로운 인스트루멘탈 힙합 ♪ ♪ 감미로운 인스트루멘탈 힙합 ♪ 반갑습니다 지금부터 이야기할 내용은 'Shared Space에 맞게 iPad와 iPhone 앱 강화하기'입니다 저는 존 마크입니다 플랫폼 호환성 엔지니어죠 iPad와 iPhone 앱 대부분은 변경 없이도 멋지게 실행됩니다 여러분이 이미 공을 많이 들여 주셨기에 Apple의 최신 플랫폼에서도 잘 작동하죠 이제 막 시작하신다면 먼저 'iPad와 iPhone 앱을 Shared Space에서 실행하기'로 시스템 내장 동작과 기능적 차이점 테스트 설정에 대해 알아보세요 이번 영상에서 이야기할 내용은 여러분의 iPad와 iPhone 앱을 단지 좋은 앱을 넘어 새로운 플랫폼에서 편안한 앱으로 강화하는 법입니다 새로운 상호 작용과 시각적 외관 변경을 검토하고 앱에서 쓸 수 있게 될 미디어 녹화와 재생 기능을 살펴보겠습니다 이 플랫폼 내 상호 작용은 재미있고 친숙합니다 새로운 자연 입력 기술이 특히 중요한 컴포넌트인데요 탭 동작으로 버튼을 보고 손가락을 함께 탭해서 상호 작용할 수 있습니다 탭 토글과 탭 동작 누르기와 밀기 동작으로 슬라이더와 상호 작용하거나 버튼을 탭할 수 있죠
다이렉트 터치를 하려면 앱으로 손을 뻗어서 공간 내의 버튼을 한 손가락으로 터치해야 합니다 어떤 상호 작용 메서드를 쓰든 버튼은 시각적 피드백을 지속적으로 제공해서 상호 작용의 정확도를 높입니다 이 영상에서 커서는 사람이 보는 위치를 나타냅니다 버튼을 보고 있으면 하이라이트 호버 이펙트로 컨트롤에 색이 입혀져 초점을 파악할 수 있습니다 목록을 살펴보는 동안 각 아이템이 하이라이트되는 데 주목하세요 하이라이트가 초점을 따라 좌우로 움직이니 상태를 분명히 알 수 있습니다 호버 이펙트는 컨트롤에 계속 남아서 사람들이 보는 곳을 알려 줍니다 비활성화 상태의 컨트롤에는 호버 이펙트가 나타나지 않죠 모든 호버 이펙트는 시스템 컨트롤에서 관리됩니다 표준 컨트롤만 사용한다면 아무런 변경 없이도 멋지게 작동하겠지만 사용자 지정 컨트롤을 사용한다면 호버 이펙트에 일부 조정이 필요합니다 앱 하나를 예시로 보여 드리죠 iPad에서 카드 기반 인터페이스를 썼습니다 카드마다 사진과 제목 날짜와 메뉴 버튼이 있네요 이번에는 동일한 앱을 시뮬레이터에서 실행했습니다 메뉴 버튼은 시스템 컨트롤이라서 이미 기대한 대로 작동하고 있지만 각 카드는 .onTap 수정자를 쓴 단순 VStack이라서 호버 이펙트를 받지 않습니다 카드 전체가 탭 타깃이니 상호 작용이 가능하다는 걸 알리려면 호버 이펙트가 필요한데요 카드 하나를 중심으로 수정해 봅시다 시스템 컨트롤은 버튼처럼 호버 이펙트를 자동으로 받으니 여기서 메뉴로 사용되는 버튼에는 이미 호버 이펙트가 들어 있죠 하지만 이 예시에서는 전체 카드를 클릭해서 자세한 내용을 보게 됩니다 VStack에 .hoverEffect를 추가하면 상호 작용 업데이트가 카드 전체에서 가능해지고 탭이 가능하다는 것도 보입니다 사용자 지정 비디오 플레이어는 다수가 타깃을 최적화해서 상호 작용 시 아주 정확하지 않아도 괜찮습니다 보여 드리는 iPad의 사용자 지정 비디오 플레이어에는 앞으로 건너뛰기와 뒤로 건너뛰기 심볼보다 탭 타깃의 크기가 확연히 큽니다 경계가 있는 상자는 탭 타깃의 크기를 보여 줍니다 상호 작용이 가능한 곳이죠 시뮬레이터에서는 호버 이펙트가 이런 탭 타깃을 하이라이트 해서 전체 영역 중에 탭이 가능한 부분을 나타냅니다 동일한 예시를 Shared Space에서 보면 호버 이펙트와 더불어 숨겨진 속성이 나타나는데요 외관이 달라져서 실제가 표시되기는 하지만 느낌이 이상합니다
예시를 확대해 보면 간소화된 외관으로 기존 탭 동작을 유지하고자 .contentShape 수정자에 사용자 지정 모양을 추가하는데요 사용자 지정 모양을 쓰면 탭 가능 영역보다 작은 .contentShape 수정자에 앱의 원점과 크기가 제공됩니다 이 비디오에서는 버튼을 똑바로 보면 호버 이펙트가 나타납니다 이렇게 변경하면 호버 이펙트 경계 밖을 탭해도 iPad와 iPhone 경험으로 생긴 기대에 부합합니다 예시가 전체적으로 보이는 시뮬레이터로 돌아가죠 사용자 지정 모양이 있으니 호버 이펙트는 버튼 위에서만 나타나지만 버튼을 벗어나도 탭이 가능합니다 아주 좋군요 시스템 컨트롤의 호버 이펙트도 대부분 잘 작동하지만 사용자 지정 호버 이펙트의 능력은 강력합니다 새로운 호버 이펙트 API로 앱에서 사용자 지정 버튼과 사용자 지정 모양을 만들고 필요하다면 컨트롤을 벗어날 수도 있습니다 어떻게 하면 되는지 검토해 봅시다 .buttonStyle을 쓰면 앱의 모든 버튼에 사용자 지정 스타일을 적용하기 좋습니다 사용자 지정 스타일을 적용하면 호버 이펙트가 꺼지죠 사용자 지정 .buttonStyle 버튼의 호버 이펙트를 다시 활성화하려면 .hoverEffect() 수정자를 앱 엘리먼트에 추가하세요 사용자 지정 버튼 스타일로 간단히 만든 제 버튼입니다 사용자 지정 버튼 스타일을 보시면 .hoverEffect 수정자를 넣어야 사용자 지정 스타일의 버튼에 호버 이펙트를 추가할 수 있습니다 많은 앱의 인터페이스는 재미있고 사용자화되어 있습니다 예시로 든 양봉 앱에서는 버튼이 벌집 모양으로 되어 있어 벌집의 칸이 저마다 탭 타깃이 됩니다 사용자 지정 모양 버튼을 구현한 앱이라면 호버 이펙트 렌더링 정보를 시스템에 줘야 하는데요 여기서는 프레임 너비가 모양의 영역보다 커서 시스템에서 기본으로 제공하는 호버 이펙트가 모양에 바운딩되지 않고 전체 버튼 프레임을 덮습니다 .contentShape 수정자에 사용자 지정 모양을 전달하면 호버 이펙트가 버튼의 경계에 맞게 잘립니다 여기에 추가해 보죠 이제 완벽하네요 사람들이 개별 버튼을 보면 호버 이펙트가 버튼 모양에 맞춰 잘립니다 앱 상태 때문에 비활성화된 시스템 컨트롤은 자동으로 호버 이펙트를 받지 않습니다 특정 인터페이스 엘리먼트에서 강조를 취소하려면 앱에서 개별 아이템을 뺄 수 있습니다 사람들은 호버 이펙트가 시스템 전체에서 분명하고 일관되기를 기대하니 이펙트는 제한적으로만 꺼져야 합니다 시스템은 동시 입력을 최대 두 개까지 받아들입니다 각 손이 개별 터치이기 때문이죠 사용자 지정 제스처 인식도 지원되는데 자연 입력 기댓값으로 부드럽게 실행되려면 업데이트가 필요할 수 있습니다 빠른 입력이나 동시 입력이 필요한 게임과 여타 앱은 게임 컨트롤러를 지원해야 합니다 iPad와 iPhone 앱은 게임 컨트롤러 지원을 예전부터 잘 보여 줬는데요 이 플랫폼에서는 추가 입력 메서드를 위해 이런 지원이 한층 더 중요합니다 Info.plist에 GCSupports ControllerUserInteraction을 넣고 게임 컨트롤러 기능을 추가하면 앱의 제품 페이지에 배지가 추가됩니다 이러면 App Store로 게임을 찾는 사용자와 소통이 개선되고 모든 플랫폼에 걸쳐 게임 컨트롤러 사용 가능 여부가 더 분명하게 나타납니다 게임 컨트롤러와 App Store 게임에 대한 정보는 다음 영상에서 보실 수 있습니다 '게임 컨트롤러 개선 사항'과 '공간 컴퓨팅을 위한 멋진 게임 빌드하기'죠 이 플랫폼에서 실행되는 iPad와 iPhone 앱은 iPad의 라이트 모드와 매칭됩니다 대부분은 아주 멋지게 보이죠 시스템 표준 컨트롤을 쓰고 레이아웃과 색상도 표준을 따르면 새로 작업하실 필요는 없습니다 동적 콘텐츠 스케일링으로 시스템이 렌더링을 최적화하니 모든 이미지와 텍스트가 항상 선명합니다 어떤 각도와 거리든 상관없죠 최고의 경험을 제공하려면 벡터 기반 콘텐츠를 사용하세요 iPad와 iPhone의 프롬프트는 모달로 나타나니 진행하기에 앞서 프롬프트와 상호 작용이 필요합니다 이 새로운 플랫폼의 프롬프트는 모달로 나타나지 않습니다 위치 정보 사용을 요청하는 프롬프트나 Apple로 로그인 혹은 OAuth 같은 프롬프트는 진행 전에 처리가 필요하지 않습니다 이런 인터페이스에서는 자기만의 화려한 기능과 윈도우형 경험을 만들 수 있습니다 앱은 프롬프트를 받았을 때 인지해 케이스를 처리해야 하지만 취소 혹은 성공 콜백을 바로 받지 못할 수도 있습니다 콘텐츠를 캡처하고 공유하며 포스팅하는 건 자신을 표현하는 멋진 방법입니다 이 플랫폼에서는 앱이 인지해야 할 몇 가지 차이점이 있는데요 내외부를 향하는 카메라가 여러 개 있지만 이런 카메라 다수는 앱에서 사용할 수 없습니다 사용할 수 있는 카메라와 마이크를 감지하는 검색 세션을 활용하는 게 아주 중요합니다 앱에서 탁월한 캡처 경험을 보장하려면 AVCaptureDevice 검색 세션으로 하드웨어 사용 가능 여부를 확인하세요 추가로 다른 플랫폼과 유사하게 사용이 요구되기 전에 허가를 요청하시고요 권한 설정 프롬프트 문자열을 범용화해서 사용을 알리는 게 마지막입니다 하드웨어나 소프트웨어 버전의 구체적인 언급은 빼고요
카메라와 마이크 사용 가능 여부를 앱이 요청하면 iPad와 iPhone과는 다른 값이 반환됩니다 앱은 마이크 정보를 쿼리할 때 단일 .front 위치 마이크를 받습니다 카메라 정보를 쿼리하면 두 카메라가 나오는데요 .back 카메라는 카메라 글리프가 없는 검은 카메라 프레임을 반환합니다 이건 비기능 카메라로 후면 카메라를 쓸 수 있다고 가정하는 앱을 지원합니다 전면 카메라 정보를 쿼리하면 앱은 콤퍼짓 카메라 하나를 찾습니다 기기에 Spatial Persona가 없다면 앱에 반환되는 카메라 프레임도 없습니다 AVRoutePickerView와 화면 속 화면은 이 플랫폼에서 사용할 수 없습니다 시스템이 제공한 플레이어에 반영되었으니까요 사용자 지정 플레이어 구현 앱은 컨트롤을 보여 주기 전에 두 기능의 사용 가능 여부를 먼저 확인해야 합니다 마지막으로 이 플랫폼은 일단 벗어나면 잠깁니다 앱이 백그라운드 오디오를 쓰면 이런 차이를 고려해야 하죠 백그라운드 모드는 기기가 잠기면 적용되지 않아서 앱이 완전히 중단되거든요 미디어를 가져오는 앱이라면 캡처 하드웨어를 쓸 수 없을 때 대안이 될 소스도 생각해야 합니다 iCloud 혹은 도큐멘트나 포토 피커 같은 콘텐츠 피커도 훌륭한 대안이 됩니다 추가로 VisionKit에서 VNDocumentCameraViewController를 쓰는 앱이면 가까운 기기에서 연속성 카메라를 통해 자동으로 캡처가 이뤄집니다 이런 대안으로 기존 iPad와 iPhone 앱에는 미디어 가져오기 옵션이 한층 더 늘어납니다 iPad와 iPhone 앱은 새 플랫폼에서 멋지게 실행됩니다 모든 상호 작용 컨트롤에 호버 이펙트가 추가되었는지 확인하고 게임에서는 플레이어가 멋진 경험을 계속하도록 컨트롤러 지원을 추가해 주세요 마지막으로는 기능을 사용하기 전에 사용 가능 여부를 확인해서 카메라와 마이크가 있는지를 검토하세요 새로운 플랫폼에 맞춰 iPad와 iPhone 앱을 최적화하는 방법을 알아봤습니다 여러분의 앱을 Shared Space에서 얼른 써 보고 싶네요 ♪
-
-
3:02 - Tappable VStack with hover effect
struct TappableCard: View { // Sample card var imageName = "BearsInWater" var headline = "Bear Fishing" var timeAgo = "42 Minutes ago" var body: some View { VStack { VStack(alignment: .leading) { Image(imageName) .resizable() .clipped() .aspectRatio(contentMode: .fill) .frame(width: 300, height: 250, alignment: .center) Text(headline) .padding([.leading]) .font(.title2) .foregroundColor(.black) } Divider() HStack { HStack { Text(timeAgo) .frame(alignment: .leading) .foregroundColor(.black) } .padding([.leading]) Spacer() VStack(alignment: .trailing) { Button { print("Present menu options") } label: { Image(systemName: "ellipsis") .foregroundColor(.black) } } } .padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)) } .frame(width: 300, height: 350, alignment: .top) .hoverEffect() .background(.white) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color(.sRGB, red: 150/255, green: 150/255, blue: 150/255, opacity: 0.1), lineWidth: 3.0) ) .cornerRadius(10) .onTapGesture { print("Present card detail") } } }
-
4:08 - Custom player with tap targets that are larger than the hover effect bounds
struct ContentView: View { var body: some View { VStack { // Video player HStack { Button { print("Going back 10 seconds") } label: { Image(systemName: "gobackward.10") .padding(.trailing) .contentShape(.hoverEffect, CustomizedRectShape(customRect: CGRect(x: -75, y: -40, width: 100, height: 100))) .foregroundStyle(.white) .frame(width: 500, height: 834, alignment: .trailing) } Button { print("Play") } label: { Image(systemName: "play.fill") .font(.title) .foregroundStyle(.white) .frame(width: 100, height: 100, alignment: .center) } .padding() Button { print("Going into the future 10 seconds") } label: { Image(systemName: "goforward.10") .padding(.leading) .contentShape(.hoverEffect, CustomizedRectShape(customRect: CGRect(x: 0, y: -40, width: 100, height: 100))) .foregroundStyle(.white) .frame(width: 500, height: 834, alignment: .leading) } } .frame( minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center ) } .frame( minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading ) .background(.black) } } struct CustomizedRectShape: Shape { var customRect: CGRect func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: customRect.minX, y: customRect.minY)) path.addLine(to: CGPoint(x: customRect.maxX, y: customRect.minY)) path.addLine(to: CGPoint(x: customRect.maxX, y: customRect.maxY)) path.addLine(to: CGPoint(x: customRect.minX, y: customRect.maxY)) path.addLine(to: CGPoint(x: customRect.minX, y: customRect.minY)) return path } }
-
5:14 - Button with custom buttonStyle, then adding a hover effect to the button
struct ContentView: View { var body: some View { VStack { Button("Howdy y'all") { print("🤠") } .buttonStyle(SixColorButton()) } .padding() } } struct SixColorButton: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding() .font(.title) .foregroundStyle(.white) .bold() .background { // Background color bands ZStack { Color.black HStack(spacing: 0) { // GREEN Rectangle() .foregroundStyle(Color(red: 125/255, green: 186/255, blue: 66/255)) .frame(width: 16) // YELLOW Rectangle() .foregroundStyle(Color(red: 240/255, green: 187/255, blue: 64/255)) .frame(width: 16) // ORANGE Rectangle() .foregroundStyle(Color(red: 225/255, green: 137/255, blue: 50/255)) .frame(width: 16) // RED Rectangle() .foregroundStyle(Color(red: 200/255, green: 73/255, blue: 65/255)) .frame(width: 16) // PURPLE Rectangle() .foregroundStyle(Color(red: 134/255, green: 64/255, blue: 151/255)) .frame(width: 16) // BLUE Rectangle() .foregroundStyle(Color(red: 75/255, green: 154/255, blue: 218/255)) .frame(width: 16, height: 500) } .opacity(0.7) .rotationEffect(.degrees(35)) } } .cornerRadius(10) .hoverEffect() } }
-
struct ContentView: View { var body: some View { VStack { Button { print("🐝") } label: { // Button label HoneyComb() .fill(.yellow) .frame(width: 300, height: 300) .contentShape(.hoverEffect, HoneyComb()) } } .frame(width: 400, height: 400, alignment: .center) .background(.black) .padding() } } } struct HoneyComb: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX + (rect.width * 0.25), y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX - (rect.maxX * 0.25), y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) path.addLine(to: CGPoint(x: rect.maxX - (rect.maxX * 0.25), y: rect.maxY)) path.addLine(to: CGPoint(x: rect.minX + (rect.width * 0.25), y: rect.maxY)) path.addLine(to: CGPoint(x: rect.minX, y: rect.midY)) path.addLine(to: CGPoint(x: rect.minX + (rect.width * 0.25), y: rect.minY)) return path } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.