스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
visionOS에서 맞춤형 호버 효과 제작하기
사용자가 뷰를 응시하면 이에 맞춰 뷰가 업데이트되는 맞춤형 호버 효과를 구현하는 방법을 알아보세요. 불투명도 및 크기 조절, 클립 효과 등을 통합하여 광범위한 버튼 효과를 빌드하는 방법을 소개합니다. 사용자의 접근성 니즈를 고려한, 편안한 효과를 만들기 위한 모범 사례도 확인해 보세요.
챕터
- 0:00 - Introduction
- 2:35 - Content effects
- 7:46 - Effect groups
- 9:40 - Delayed effects
- 12:09 - Accessibility
리소스
관련 비디오
WWDC24
WWDC23
-
다운로드
’visionOS에서 맞춤형 호버 효과 제작하기’를 시작하겠습니다 저는 SwiftUI 팀의 엔지니어 Christian입니다 이 비디오에서는 맞춤형 호버 효과 API를 사용해 사용자가 SwiftUI 뷰를 응시할 때 이 뷰가 응답하게 만드는 방법을 알아봅니다
visionOS에서 대화형 영역은 사용자가 응시할 때 강조됩니다 이러한 강조는 호버 효과를 사용해 적용됩니다 호버 효과를 사용하면 앱의 반응성이 뛰어나다고 느껴지며 손가락을 함께 탭할 때 어떤 요소가 트리거될지에 대한 피드백을 제공합니다
호버 효과는 표준 제어기에 자동으로 추가되며 hoverEffect 뷰 한정자를 사용해 맞춤형 제어기에 추가됩니다 강조와 같은 표준 효과에 대한 자세한 내용은 ’공간 컴퓨팅에 맞게 윈도우형 앱 향상하기’를 참고하세요
기본 강조 효과는 대부분의 경우 잘 작동하지만 일부 뷰는 맞춤형 효과로 이점을 얻을 수 있습니다
슬라이더는 노브를 표시해 상호작용을 권합니다 뒤로 버튼은 크기가 커져서 이전 페이지 이름을 표시합니다 탭 막대는 열리면서 레이블을 표시합니다
Safari 탐색 막대는 확장되어 브라우저 탭을 표시합니다
visionOS 2의 새로운 맞춤형 호버 효과 API 덕분에 다음과 같이 맞춤형 호버 효과를 빌드할 수 있습니다
맞춤형 호버 효과는 앱의 어디서나 SwiftUI 뷰에 적용될 수 있습니다 여기에는 오너먼트, 실제 뷰 첨부 등이 포함됩니다 이러한 효과는 사용자가 뷰를 응시하거나 손가락을 뻗거나 그 위로 마우스 커서를 가져갈 때 적용됩니다
맞춤형 호버 효과는 처음부터 개인정보를 보호하도록 설계됩니다 앱 프로세스 외부에서 시스템에 의해 적용되며 추가 권한이나 확장 프로그램이 필요하지 않습니다
RealityKit에는 3D 콘텐츠에 호버 효과를 적용하는 새 API도 있죠 자세한 내용은 ’RealityKit의 새로운 기능’을 참고하세요 이 API를 여러분과 공유하게 되어 정말 기쁩니다 먼저, 콘텐츠 효과를 사용한 뷰의 스타일 변경 방법을 설명하고 효과 그룹을 사용해 여러 효과를 함께 적용하는 방법을 알아봅니다 다음으로, 지연된 효과를 사용해 효과의 타이밍을 제어하는 방법을 설명하겠습니다
마지막으로, 새 CustomHoverEffect 프로토콜을 사용해 사용자의 선호도에 따라 조정되는 재사용 가능한 효과를 제작합니다 콘텐츠 효과에 대한 이야기를 시작해 보죠!
콘텐츠 효과는 뷰의 모습을 변경하는 기본적인 효과입니다 뷰의 불투명도를 변경하거나 지오메트리를 변형하거나 클립 형태를 적용합니다 이러한 효과는 뷰의 모습만 변경하며 근처 뷰의 레이아웃에는 영향을 미칠 수 없습니다 효과는 두 가지 상태 사이에 전환됩니다 뷰를 보고 있지 않으면 효과가 비활성 상태를 적용합니다
누군가가 뷰를 보고 있으면 효과가 활성 상태로 전환되고 뷰를 업데이트합니다 이 크기 효과와 같은 지오메트리 효과는 뷰의 지오메트리를 수정합니다
클립 효과는 뷰의 숨겨진 부분을 표시합니다 불투명도 효과는 콘텐츠를 페이드 인 또는 페이드 아웃합니다 누군가가 뷰가 아닌 다른 곳으로 시선을 돌리면 효과가 다시 비활성 상태로 전환됩니다 여러 효과를 조합하여 세부적인 전환을 구현할 수 있습니다 이 확장 효과처럼요 제가 작업 중인 비디오 재생 앱을 위해 이 효과를 개발해 왔는데요 이 비디오의 나머지 부분에서는 이 효과를 완벽해질 때까지 단계별로 빌드해 보겠습니다
시뮬레이터에서 실행되는 Destination Video 앱입니다 왼쪽 상단에는 프로파일을 전환하는 버튼을 추가했습니다 제가 이 버튼을 바라보면 버튼이 강조됩니다 완전한 확장 효과를 빌드하는 첫 단계로 버튼을 바라보면 버튼이 커지게 만들어 보겠습니다 코드를 작성해 보죠
맞춤형 버튼에 이미 아이콘과 세부 텍스트를 배치했으며 맞춤형 버튼 스타일을 사용해 버튼의 모양을 제어합니다 여기에 크기 효과를 추가하겠습니다
ButtonStyle 내부에 hoverEffect 한정자를 사용해 기본 강조 효과를 추가했습니다
크기 효과를 위해 새 블록 기반 hoverEffect 한정자를 추가합니다
블록 내부에서 scaleEffect와 같은 한정자로 활성/비활성 상태 간에 전환할 때 뷰의 모양을 변경할 수 있습니다
블록이 isActive가 true인 상태로 호출되면 효과의 활성 상태를 isActive가 false인 상태로 호출되면 비활성 상태를 가져오죠 효과는 시스템에서 적용하므로 이 호출은 호버가 실제로 발생할 때가 아니라 사전에 이루어집니다
버튼을 바라보면 버튼이 커지게 하려는 것이므로 활성이면 5% 크기를 적용하고 비활성이면 적용하지 않습니다 시뮬레이터에서 이를 확인해 보겠습니다
버튼을 바라보면 강조 효과와 크기 효과가 함께 적용됩니다
이제 클립 효과를 사용해 버튼의 세부 텍스트를 숨기고 표시합니다 클립 효과는 뷰에서 표시되는 부분을 변경하며 활성 상태가 아니면 추가 콘텐츠를 숨기는 데 사용될 수 있습니다 효과가 활성 상태가 되면 클립 효과가 확장되어 이전에 숨긴 콘텐츠를 표시할 수 있습니다
다시 맞춤형 버튼 스타일에서 기존의 clipShape 한정자를 hoverEffect 블록 내로 이동하면 효과가 활성/비활성 상태가 되면 clipShape를 변경할 수 있습니다
클립 도형의 크기를 변경하려면 도형에 크기 한정자를 추가하고 호버 효과 블록에 제공된 지오메트리 프록시를 사용해 활성 및 비활성 클립 도형의 크기를 계산합니다
효과가 활성 상태이면 clipShape가 버튼의 너비로 확장되어 전체 버튼이 표시되어야 합니다 하지만 효과가 비활성 상태이면 버튼이 원형이어야 하며 아이콘만 표시되어야 합니다 따라서 clipShape의 너비와 높이가 일치하게 만들어서 원형 clipShape가 되도록 하겠습니다
마지막으로, 새로운 anchor 매개변수를 사용해 클립 도형을 버튼의 시작 가장자리에 맞춰 정렬하여 효과가 비활성 상태이면 아이콘이 표시되도록 합니다 시뮬레이터에서 확인해 보겠습니다
놀랍네요! 버튼을 바라보면 버튼이 확장되고 시선을 돌리면 축소됩니다 효과를 좀 더 세련되게 다듬어 보겠습니다 버튼이 확장되면서 세부 텍스트가 페이드 인되도록 해 보죠
텍스트만 페이드되어야 하니 세부 텍스트에 hoverEffect를 추가하고 텍스트를 0에서 1까지 페이드하는 불투명도 효과를 적용합니다
비슷하게 구현됐지만 조금 아쉽네요 다시 살펴보겠습니다
버튼이 확장되면서 세부 텍스트가 페이드 인되게 하고 싶었지만 세부 텍스트가 표시될 공간을 바라볼 때에야 페이드 인됩니다 그 이유를 알아보겠습니다
호버 효과는 연결된 뷰를 누군가가 바라보면 활성 상태가 됩니다 전체 버튼에 크기 효과와 clipShape 효과를 적용했으므로 버튼 내부의 아무 곳이나 바라보면 해당 효과가 활성 상태가 됩니다 하지만 세부 텍스트에 불투명도 효과를 적용했기 때문에 텍스트를 바라봐야만 활성 상태가 됩니다 그 대신에 효과들을 함께 활성화시킬 방법이 필요합니다 이를 위해 효과 그룹을 사용하겠습니다 그룹화된 효과는 함께 적용됩니다 그룹에 포함된 효과 중 하나가 활성 상태가 되면 모두 적용됩니다 서로 다른 여러 뷰의 효과가 함께 그룹화되면 해당 뷰 중 하나를 바라보면 그룹이 활성 상태가 됩니다 즉, 효과 그룹은 앱의 어떤 영역이 효과를 활성화하는지를 제어합니다
효과를 그룹화하는 2가지 방법은 명시적 방식과 묵시적 방식입니다 우선, 이러한 효과를 명시적으로 그룹화해 보겠습니다
이를 위해 HoverEffectGroup을 생성하여 그룹을 나타냅니다 Namespace를 사용해 그룹에 고유한 ID를 제공합니다
이제 그룹에 ID를 부여했으니 불투명도 효과부터 시작해 각 효과를 그룹에 명시적으로 추가합니다 그룹을 ButtonStyle에 제공하고 나머지 효과를 그룹에 추가합니다 모든 효과가 동일한 그룹에 속하므로 이제 모두가 단일 효과로 함께 활성화됩니다
정말 훌륭하네요! 모든 효과가 함께 활성화됩니다 버튼이 확장되면 텍스트가 페이드 인되고, 축소되면 페이드 아웃되죠
이와 같은 효과를 명시적으로 그룹화하면 효과를 그룹화할 때 유연성과 제어력이 극대화됩니다 하지만 이 정도 수준의 제어력이 필요하지 않다면 효과를 묵시적으로 그룹화할 수도 있습니다
hoverEffectGroup을 모든 효과에 제공하는 대신에 hoverEffectGroup 한정자를 뷰에 추가하기만 하면 됩니다 이 뷰와 하위 뷰의 모든 효과가 그룹에 묵시적으로 추가됩니다 따라서 모든 효과에 그룹을 추가할 필요가 없습니다 또한 한정자에 그룹을 제공하지 않으면 그룹도 묵시적으로 생성됩니다 정말 편리하죠? 프로파일 버튼은 정말 유용합니다 저는 콘텐츠 효과와 효과 그룹을 사용해 필요한 모든 시각적 변경 사항을 적용했습니다 하지만 보는 즉시 버튼이 확장되어 집중을 방해할 수 있습니다 잠시 기다렸다가 확장된다면 더 좋을 것입니다 지연된 효과를 사용해 버튼의 확장 시점을 제어할 수 있습니다 기본적으로 호버 효과는 즉시 적용되므로 상호작용을 권하는 미세한 효과에 적합합니다 하지만 거의 모든 효과는 짧은 지연으로도 이점을 얻습니다 이는 사용자가 앱을 훑어볼 때 잠시 활성화되는 것을 방지합니다
어떤 사용자도 완벽하게 가만히 있지는 않습니다 지연을 사용해 효과를 잠시 활성 상태로 유지하면 이 자연스러운 동작을 수용하고 깜박임을 방지할 수 있습니다
끝으로, 추가 콘텐츠를 표시하는 효과는 지연이 더 길어야 합니다 이러한 효과는 쉽게 집중을 방해하며, 사용자가 앱의 특정 요소에 집중하고 있을 때 잠시 유보되어야 합니다 적절한 지연은 효과마다 각각 다르므로 항상 Apple Vision Pro 착용 시 효과를 사용하여 적정 수준을 파악하세요
프로파일 버튼은 콘텐츠를 표시하니 긴 지연을 추가합니다
지연을 적용하기 위해 애니메이션 한정자에 효과를 래핑하고 지연된 애니메이션을 제공합니다
기본 애니메이션을 사용하되 효과가 활성 상태가 되면 지연을 더 길게 하고 효과가 비활성 상태가 되면 지연을 더 짧게 했습니다
하지만 scaleEffect는 지연시키지 않을 것입니다 이 효과는 즉각적인 피드백을 제공하므로 즉시 적용돼야 합니다
버튼이 확장되면서 텍스트가 페이드 인되므로 동일한 애니메이션을 불투명도 효과에 적용합니다 그러면 효과가 동기화된 상태로 유지됩니다 이제 효과에 지연이 적용되었으니 효과를 다시 사용해 보겠습니다
좋습니다 버튼은 크기 효과와 강조 효과를 통해 여전히 즉각적인 피드백을 제공합니다 하지만 확장되기 전에 잠시 기다립니다
계속 진행하기 전에, 애니메이션 효과를 좀 더 살펴보겠습니다
애니메이션이 지정되지 않으면 SwiftUI 기본 애니메이션이 사용되죠
호버 효과는 선형, easeOut 스프링 애니메이션 같은 익숙한 애니메이션, 맞춤형 타이밍 커브가 적용된 애니메이션을 지원합니다
하지만 CustomAnimation 유형은 지원되지 않습니다 앱의 프로세스 외부에서 적용할 수 없기 때문입니다
저는 확장되는 버튼이 정말 마음에 듭니다 하지만 동작에 민감한 사용자는 불편하게 느낄 수 있습니다 효과를 구현할 때는 항상 손쉬운 사용을 고려해야 하며 필요한 경우 대체 효과를 제공해야 합니다 이 비디오의 나머지 부분에서는 프로파일 버튼을 업데이트하여 ’동작 줄이기’ 설정이 활성화된 경우 크로스페이드 효과를 사용하도록 합니다
버튼은 여전히 제가 바라보는지에 따라 확장 및 축소되지만 페이드 효과를 대신 사용합니다 저는 세부 텍스트를 페이드 인하는 페이드 효과를 이미 작성했습니다 효과를 하나 더 제작하는 대신에 새로운 CustomHoverEffect 프로토콜을 사용하여 두 곳에서 모두 사용할 수 있는 효과를 제작하겠습니다
재사용 가능한 효과를 제작하기 위해 앞서 작성했던 hoverEffect 한정자를 CustomHoverEffect를 따르는 새 FadeEffect 유형에 복사합니다
효과의 본문 메서드 내에서 뷰에서 사용한 동일한 hoverEffect 한정자에 접근할 수 있습니다 따라서 손쉽게 간단히 시작한 다음 필요한 대로 리팩터링합니다 이제 이 효과는 고유한 유형이므로 더 유용하게 만들 수 있습니다 비활성 및 활성 불투명도 값의 맞춤화를 허용하면 됩니다
이제 재사용 가능한 페이드 효과를 제작했으니 다시 돌아가서 버튼 뷰에서 이를 사용하겠습니다 hoverEffect 블록을 제거하기만 하면 됩니다 그리고 이를 새로운 FadeEffect()로 대체합니다
이렇게 하면 코드가 재사용 가능해지고 더 깔끔해집니다 확장 효과를 CustomHoverEffect 유형으로도 이동하겠습니다 이전과 마찬가지로 뷰에서 hoverEffect 블록을 이동해 새로운 CustomHoverEffect 유형에 배치하고 ExpandEffect로 부릅니다
버튼 스타일로 돌아가서 블록 기반 hoverEffect를 제거하고
새로운 ExpandEffect()로 대체합니다 좋습니다!
이제 모든 효과를 재사용할 수 있으므로 프로파일 버튼을 업데이트해 동작 줄이기가 활성화되면 크로스페이드하도록 지정할 수 있습니다
먼저, @Environment 속성을 추가해 reduceMotion 설정에 접근합니다
reduceMotion이 활성화된 경우 ExpandEffect()가 적용되면 안 되므로 빈 효과를 대신 적용하겠습니다 빈 효과는 아무것도 수행하지 않는 효과입니다 다양한 효과 간에 동적으로 전환하려면 각 항목을 HoverEffect 유형에 래핑해 개별 유형을 지워야 합니다 끝나지 않았지만, 시뮬레이터에서 진행 상황을 확인하겠습니다
백그라운드 도형은 아직 적절하지 않지만 버튼은 더 이상 확장되지 않으며 세부 텍스트는 여전히 예상대로 페이드 인됩니다 텍스트와 동기화되어 크로스페이드하도록 백그라운드를 업데이트하려면 기존 백그라운드를 2개의 별도 백그라운드 뷰로 대체합니다 첫 번째는 버튼의 너비만큼 확장되는 Capsule()이며 제가 버튼을 바라보면 표시됩니다 reduceMotion이 활성화된 경우 FadeEffect를 여기에 적용합니다
두 번째로, 원형 백그라운드는 제가 버튼을 바라보고 있지 않을 때만 표시되어야 합니다 여기에도 맞춤형 불투명도 값을 사용해 FadeEffect를 적용하면 나머지 하나가 페이드 인되면서 이것이 페이드 아웃됩니다
잘 작동하네요! 버튼을 바라보면 백그라운드와 세부 텍스트가 페이드 인됩니다 확장되는 버튼이 완성되었습니다! 세부 효과를 제작하기 위해 간단한 효과를 몇 가지만 결합했습니다 적절한 지연을 선택하고 사용자의 손쉬운 사용 선호도를 존중하여 효과가 누구에게나 만족스럽도록 했습니다 이제 창의력을 발휘해 맞춤형 호버 효과를 빌드해 보세요! 간단하게 시작하고, 단계별로 효과를 빌드하는 것이 좋습니다 하지만 프로파일 버튼의 아이콘 등 뷰의 일부는 정적으로 유지하세요 이러한 고정 요소는 효과 전환 중에 연속성을 제공합니다 물론, 효과를 철저히 테스트해 보세요 시뮬레이터는 빠른 반복을 위해 유용하지만 효과가 어떻게 느껴지는지 파악하는 유일한 방법은 Apple Vision Pro를 착용하고 테스트해 보는 것입니다
멋진 호버 효과 제작에 대한 팁은 업데이트된 휴먼 인터페이스 가이드라인을 참고하세요 시청해 주셔서 감사합니다!
-
-
4:06 - Button with Scale Effect
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .clipShape(.capsule) .hoverEffect { effect, isActive, _ in effect.scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
5:37 - Button with Clip and Scale Effects
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
6:50 - Expanding Button with Ungrouped Fade
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
8:19 - Expanding Button with Explicit Group
struct ProfileButtonView: View { var action: () -> Void = { } @Namespace var hoverNamespace var hoverGroup: HoverEffectGroup { HoverEffectGroup(hoverNamespace) } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(in: hoverGroup) { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle(hoverGroup: hoverGroup)) } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } } struct ProfileButtonStyle: ButtonStyle { var hoverGroup: HoverEffectGroup? func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight, in: hoverGroup) .hoverEffect(in: hoverGroup) { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } }
-
9:13 - Expanding Button with Implicit Group
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
10:51 - Expanding Button with Delayed Effect
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? 1 : 0) } } } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
12:50 - Expanding Button with Reusable Effects
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(FadeEffect()) } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect(ExpandEffect()) } } struct ExpandEffect: CustomHoverEffect { func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct FadeEffect: CustomHoverEffect { var from: Double = 0 var to: Double = 1 func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? to : from) } } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
14:14 - Final Expanding Button with Accessibility Support
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(FadeEffect()) } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { @Environment(\.accessibilityReduceMotion) var reduceMotion func makeBody(configuration: Configuration) -> some View { configuration.label .background { ZStack(alignment: .leading) { Capsule() .fill(.thinMaterial) .hoverEffect(.highlight) .hoverEffect( reduceMotion ? HoverEffect(FadeEffect()) : HoverEffect(.empty)) if reduceMotion { Circle() .fill(.thinMaterial) .hoverEffect(.highlight) .hoverEffect(FadeEffect(from: 1, to: 0)) } } } .hoverEffect( reduceMotion ? HoverEffect(.empty) : HoverEffect(ExpandEffect()) ) } } struct ExpandEffect: CustomHoverEffect { func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct FadeEffect: CustomHoverEffect { var from: Double = 0 var to: Double = 1 func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? to : from) } } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.