스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI의 새로운 기능
SwiftUI 파티에 초대되셨습니다! 참석하셔서 최신 업데이트를 알아보고 UI 프레임워크 디자인의 미래를 살펴보시기 바랍니다. SwiftUI에서 깊이 있는 맞춤화, 고급 레이아웃 기술, 적절한 공유 전략, 앱의 전체 디자인을 위한 견고한 구조적 접근 방식을 확인할 수 있습니다. 또한 최신 그래픽 효과와 API를 탐색하는 즐거움도 느껴볼 수 있습니다.
리소스
관련 비디오
WWDC22
-
다운로드
♪부드러운 힙합 반주♪ ♪ 안녕하세요, 저는 Nick입니다 저는 Franck입니다 저희는 SwiftUI 엔지니어인데요 오늘 진행할 코너는 'What's new in SwiftUI'입니다 SwiftUI는 Apple의 운영체제와 함께 발전하며 서로의 한계를 시험하고 있습니다 Apple은 여러분이 SwiftUI로 만들어내는 결과물에 끊임없이 놀라며 기뻐하고 있습니다 저희는 커뮤니티의 다양한 피드백에 귀 기울입니다 그래서 저희가 올해 집중한 부분을 공유하게 되어 특히 기쁜데요 저희는 올해의 API를 더 깊이 있게 제작했습니다 더 많은 맞춤형 경험을 가능하게 했고 놀라운 신규 그래픽 기술을 도입했습니다 새로운 SwiftUI 앱 구조와 그 이상까지 설계했죠 SwiftUI 덕분에 플랫폼의 미래를 반영하는 디자인과 기능을 구축할 수 있었습니다 널리 쓰이는 앱의 재디자인부터 완전히 새로운 기능은 물론 심도 깊은 시스템 통합까지 말이죠 Apple 내의 포괄적 활용은 SwiftUI의 진화를 더욱 밀어붙입니다 이처럼 새로운 디자인과 기능은 SwiftUI 덕분에 Apple이 앱을 쓰는 방식이 진화되었기 때문에 가능합니다 오늘 저희는 이와 같은 API를 기념하고... SwiftUI의 생일도 축하하려고 합니다 프랑크와 저는 운 좋게도 축하 파티를 함께 기획하게 됐습니다 이번 파티에서 하게 될 활동을 설명해 드리죠 제가 소개할 것은 새로운 프레임워크인 Swift Charts인데 저희의 모든 플랫폼에서 보기 좋은 데이터 시각화를 가능하게 해줍니다 저는 SwiftUI의 내비게이션용 데이터 주도 강 타입 모델과 새로운 창 기술을 보여드리겠습니다 프랑크는 새로운 컨트롤 모음과 기존 컨트롤의 깊이 있는 커스텀을 보여드릴 겁니다 그리고 Transferable 프로토콜을 이용해 SwiftUI 세계에 공유 기능을 멋지게 적용한 법을 알려드린 후 기발하고 새로운 그래픽 API와 새로운 고급 레이아웃 API를 제가 소개하며 마치겠습니다 그럼 Swift Charts부터 시작하죠 Swift Charts는 서술문 프레임워크로 아름다운 상태 주도 차트를 구축하는 데 이용됩니다 핵심적인 디자인 원리로 대단한 SwiftUI를 만들고 데이터 플로팅 과정에서 Swift Charts를 세계 최상급의 데이터 시각화 프레임워크로 만들도록 조화롭게 구성되었습니다 프랑크와 저는 파티가 시작되기 전에 완료할 작업 사항 개수를 바 차트로 표현했습니다 Swift Charts는 단 몇 줄의 코드로 커스텀 가능한 차트를 만들어냈습니다 Swift Charts는 SwiftUI처럼 지능적인 기본값을 골라줍니다 프레임워크가 y축에 적을 값을 만족스러운 어림수로 정했고 바가 표시되는 색깔 기본값도 제공했죠
SwiftUI를 아신다면 Swift Charts의 서술적인 상태 주도 구문이 보일 겁니다 차트는 View의 한 종류일 뿐이므로 리스트나 테이블처럼 서술하면 됩니다 데이터를 제공한 다음 그 데이터를 이용해 차트 내용을 구축하면 되죠 이 차트의 경우 저는 BarMark를 선택했지만 LineMark로 바꾸고 포그라운드 양식을 추가해서 카테고리별로 묶으면 그 이상을 볼 수 있습니다 Swift Charts가 각 카테고리를 하나의 줄로 표현하고 차트 범례를 자동으로 추가하죠 차트에 개성을 더하는 건 재미있는 작업입니다 LineMark의 심볼 변경자로 선에 점을 입힐 수도 있죠 이 변경자는 SwiftUI 변경자와 같습니다 차트 안에서 SwiftUI view를 사용할 수도 있죠 차트에 대한 데이터 입력값은 마치 리스트처럼 ForEach에 대신 전달될 수 있습니다 이로써 차트 편집기에 더 많은 표식을 더할 수 있습니다 일일 목표를 표시하는 RuleMark처럼 말이죠
SwiftUI의 정신이 다시 한번 빛을 발합니다 Swift Charts는 언어별 버전과 다크 모드 동적 타입을 자동으로 지원합니다 물론 저희의 플랫폼 전체에서 작동합니다 여러분만의 차트를 만드는 방법이 궁금하다면 'Hello Swift Charts'를 확인해보시고 고급 플로팅 기술에 관심이 있다면 'Raise the bar' 세션을 들어보세요 다음으로는 내비게이션과 창에 대해 알아봅시다 SwiftUI는 이미 가장 일반적인 앱 내비게이션 패턴을 지원합니다 예를 들어 몰입형 푸시앤팝 NavigationStack이나 섬세한 확장형 Split View 그리고 강력한 다중 창 경험 등을 말이죠
올해 SwiftUI는 이 세 가지 패턴을 대대적으로 업데이트했습니다 스택부터 시작하죠 SwiftUI는 새로운 컨테이너 view를 지원합니다 간단히 부르면 NavigationStack이며 푸시앤팝 스타일 내비게이션을 지원합니다 NavigationStack 안에는 루트 콘텐츠 view가 접혀있습니다 저희의 파티 플래너 앱 음식 재고 리스트처럼요 여러분이 예상하시듯 기존 API와 잘 호환됩니다 NavigationLink나 navigationTitle()처럼요 링크 하나를 선택하면 SwiftUI가 스택 맨 위에 상세 view를 띄웁니다 저희 앱에는 각 상세 view에 더 많은 링크를 넣었는데요 관련 음식 항목을 쉽게 둘러보기 위해서입니다
이런 접근법만 있어도 충분할 수 있으나 view를 제시하고 이를 프로그래밍으로 컨트롤 할 수 있는 새로운 방법이 있습니다 NavigationStack 상태를 컨트롤해야 한다면 새로운 데이터 주도 API를 적용해 보세요 새로운 navigationDestination() 변경자를 이용하면 내비게이션 대상을 특정 데이터 유형에 연결할 수 있습니다
저희는 올해 NavigationLink에 새로운 재주를 가르쳤습니다 대상 view 대신 대상을 상징하는 값을 가질 수 있죠 링크를 선택하면 SwiftUI가 값 유형을 사용해 올바른 대상을 찾아 스택에 푸시합니다 예전과 똑같이 말이죠 이제는 데이터를 사용해 스택을 주도하기 때문에 현재 내비게이션 경로를 명시적 상태로 표시할 수 있습니다 이 경우 내비게이션 경로는 단순히 저희가 액세스한 모든 음식 항목의 배열입니다 이 상태에 직접적인 접근이 가능하므로 처음 선택한 항목으로 재빨리 돌아가는 버튼을 아주 쉽게 만들 수 있습니다 view가 스택에 푸시되면 선택된 음식 항목 배열에 항목이 첨부됩니다 버튼만 누르면 경로의 첫 번째 항목만 제외하고 나머지를 전부 제거할 수 있습니다
선택 한 번으로 시작점으로 돌아왔죠
이제 다중열 내비게이션을 위한 Split View 차례입니다 다중열 내비게이션을 위한 NavigationSplitView라는 새로운 컨테이너를 소개합니다 NavigationSplitView는 2, 3열 레이아웃을 서술할 수 있습니다 파티 플래너 앱은 간단한 2열 레이아웃을 사용합니다 사이드바 안에는 파티 플래너 작업 리스트가 있고 태스크를 선택할 때마다 상세 view 내용이 바뀝니다 Split View는 아까 설명한 새로운 값 기반 NavigationLinks와 잘 호환되며 링크의 값을 이용해서 리스트의 선택을 주도합니다 보다 작은 클래스나 기기에서는 NavigationSplitView가 스택 안에 자동으로 접히기 때문에 적응형 다중 플랫폼 앱을 구축하기에 훌륭한 도구가 되죠 NavigationSplitView와 NavigationStack은 함께 사용하도록 설계됐으며 보다 복잡한 내비게이션 구조를 구축할 수 있도록 직접적으로 구성할 수 있습니다 이것을 파티 플래너 앱에서 사용하여 상세 사항 열을 자체 자립적인 내비게이션 스택으로 만들었습니다 macOS의 내비게이션 스택에 대한 새로운 지원도 자랑스럽게 소개합니다
음식에 대한 얘기를 많이도 했군요 그런데 제 동료 커트가 설명할 게 남았다고 하는데요 'The SwiftUI cookbook for navigation'에서 확인하세요 내비게이션 스택과 내비게이션 Split View에 관해 더 많은 정보를 알게 될 겁니다 하지만 우선은 하던 얘기를 계속합시다 다음은 새로운 Scene API입니다 WindowGroup은 이미 익숙하시겠죠 앱 메인 인터페이스를 구축하기 좋은 방식이죠 다중 창을 만들어서 앱 데이터에 다양한 관점도 부여할 수 있는데요 올해 새롭게 창을 추가했습니다 여러분도 예상하셨겠지만 앱을 위한 하나뿐인 고유의 창을 서술해줍니다 화면을 보시면 파티 예산 창을 추가했는데요 파티의 총비용이 나옵니다
창 기능은 기본으로 사용 가능하며 앱의 Window 메뉴를 선택하면 보입니다 더 쉬운 방법도 있는데요 Command-0 단축키를 설정해 창을 열면 됩니다 파티 예산을 초과하지 않기 위해 툴바 버튼을 추가해 볼 건데요 역시 이 창을 보여주는 액션을 이용할 겁니다 환경 액션 openWindow를 이용해 SwiftUI가 관리하는 창을 프로그램적으로 열 수 있습니다 게다가 저희는 올해 새로운 창 커스텀 모음을 추가했으며 기본 사이즈, 위치 크기 조절 등에 대한 변경자도 포함되어 있습니다 파티 예산이 작업에 방해되면 안 되겠죠 창은 자동으로 구석에 작게 보입니다 창의 위치나 크기를 바꾸면 앱이 시작되어도 SwiftUI가 자동으로 이를 기억합니다 새로운 독립 창 Scene은 Mac에서 보여드린 것처럼 작은 보조 창을 만들기 좋습니다 하지만 파티 플래너는 다중 플랫폼 앱이기에 더 작은 화면에서 잘 보이는 디자인이 필요합니다 예를 들어 iOS에서 예산 창을 표시할 때는 크기 조절이 가능한 시트를 사용했습니다 새로운 presentationDetents() 변경자를 이용했기에 가능했죠 이 경우 저는 크기 조절이 가능한 시트를 형성했는데요 두 가지 크기로 조절됩니다 하나는 250포인트이며 다른 하나는 시스템이 규정하는 중간 높이입니다 올해는 다른 플랫폼에서 반복 적용하기도 쉽습니다 Xcode의 다중 플랫폼 타깃을 사용하여 SwiftUI 기반 앱을 강화할 수 있기 때문이죠 하나의 타깃을 여러 플랫폼에 적용할 수 있습니다 Xcode의 툴바 풀다운 메뉴에서 사용하는 플랫폼을 선택하세요 'What's new in Xcode'와 'Use Xcode to develop a multiplatform app'을 통해 더 많은 정보를 접하세요 마지막으로 보여드릴 새로운 Scene 유형입니다 메뉴 바로 시선을 돌려보죠 이제 macOS Ventura에서도 MenuBarExtras가 구축됩니다 SwiftUI만을 이용해서 말이죠! 이는 앱의 다른 Scene 유형과 함께 정의될 수 있으며 앱이 실행되는 동안 항상 메뉴 바에 표시됩니다 혹은 MenuBarExtra만 사용해도 앱 전체를 구축 가능합니다! 아무리 간단한 아이디어라도 macOS에서 살려낼 수 있으니 그야말로 흥미로운 방법이죠 'Bring Multiple Windows to your SwiftUI App'에서 새로운 Scene 유형과 기능을 모두 활용하는 법을 자세히 배워보세요 창을 컨트롤하는 법을 배웠으니 이제 프랑크와 함께 창에 컨트롤을 넣어 보죠 고마워요, 닉! 저희는 올해 모든 API의 기능을 다양하게 강화해서 인터랙티브 콘텐츠를 구축할 수 있게 했습니다 할 얘기가 많지만 오늘은 흥미로운 폼 기능 강화부터 보시죠 macOS Ventura에는 새로운 System Settings 앱이 있습니다 능률적인 내비게이션 구조를 자랑하는데요 아까 닉이 설명해 줬던 내비게이션 Split View와 스택을 사용해 구축했죠 신선하면서도 세련된 인터페이스 스타일을 자랑합니다 인터페이스 설정은 컨트롤이 많기 때문에 이 스타일은 특별히 디자인됐습니다 많은 컨트롤이 포함된 폼을 일관되고 잘 정리된 형태로 보여주기 위해서죠 저희의 파티 플래너 앱에도 이 새로운 디자인을 적용했답니다 한 번 살펴보죠 파티 상세 사항 view에서도 여러 유형의 컨트롤이 섹션별로 나뉘어 있습니다 설정 인터페이스와 비슷한 역할을 하죠 덕분에 System Settings의 새로운 시각적 스타일을 차용하기 쉬워졌습니다
새롭게 나뉜 macOS의 formStyle을 사용해 이 디자인을 활성화할 수도 있어요 SwiftUI의 서술적 API가 지닌 유연성 덕분에 폼 안의 내용과 컨트롤이 새로운 스타일에 자동으로 적용됩니다 예를 들어 각 섹션은 헤더 아래의 내용을 시각적으로 구분하며 컨트롤은 라벨과 값을 일관되게 조절해 행간 및 끝단 기준선에 맞도록 합니다 일부 컨트롤은 시각 테마도 바꿀 수 있습니다 토글이 끝단 미니 스위치로 보이는 것처럼 말이죠 일관된 레이아웃과 배치를 위해서입니다 폼 자체가 여러 가지의 시각 구조를 제공하므로 다른 컨트롤의 경우 보다 경량의 시각 테마로 이 컨텍스트에 적응하며 롤오버 시 더 우수한 컨트롤 지원을 제공합니다 SwiftUI는 LabeledContent view를 이용해 다른 유형의 내용도 새로운 스타일로 쉽게 배열합니다 이를 이용하면 새로운 컨트롤을 구축하거나 읽기 전용 정보를 표시할 수도 있죠 이 경우에는 파티 위치에 대한 텍스트를 표시하고 있는데요 SwiftUI가 자동으로 스타일을 조절해 텍스트를 선택할 수 있게 허용해 줍니다
만약 커스텀 view를 사용하여 전체 주소를 표시하고 싶다면 LabeledContent는 모든 view를 접을 수 있습니다 더 영리해진 SwiftUI가 텍스트에 기본 스타일을 적용하죠 다른 경우도 마찬가지입니다 컨트롤 라벨 안에서 텍스트 여러 조각을 계층적으로 구성해 제목과 부제를 붙입니다 이 새로운 폼 디자인은 macOS에서 특히 멋지지만 저희 앱의 iOS 버전에서도 같은 코드의 대부분을 함께 사용할 수 있습니다
iOS 개선된 디자인도 확인하실 수 있을 겁니다 이 팝업 메뉴 피커의 경우 macOS식 비주얼 스타일인데 인터랙션과 테마가 터치 기반 인터페이스에 잘 맞도록 최적화되어 있습니다 물론 iPad의 큰 화면에서도 같은 코드가 잘 작동합니다 Mac에서도 마찬가지죠 이처럼 다중 플랫폼 인터페이스를 구축할 때 SwiftUI의 서술적 모델은 아주 유용합니다 모든 플랫폼에서 작동하는 코드를 만들어주니까요 물론 폼 스타일 외에 컨트롤 기능도 개선 중입니다 그러니 파티 플래너 앱에서 저희가 사용 중인 그 밖의 새로운 컨트롤 기능을 빠르게 둘러보도록 하죠 iOS 앱의 새로운 활동 페이지를 봅시다 새로운 축 변수를 이용하면 텍스트 영역이 세로로 확장되도록 설정할 수 있습니다 텍스트에 맞게 높이를 키우는 거죠 줄 경계까지 높이를 제한하도록 설정할 수도 있습니다 개선된 lineLimit 변경자는 고급 기능도 제공하는데 최소한의 공간을 남겨두었다가 내용이 추가되면 확대되고 내용이 상단 경계를 초과하면 스크롤 할 수 있습니다 텍스트 영역 아래에는 새로운 MultiDatePicker라는 컨트롤 예시가 보입니다 서로 접하지 않은 날짜를 선택하게 해 줘서 파티 활동을 자유롭게 설정할 수 있죠
이제 파티에 갈까 말까 망설이는 사람들이 생겨났을지도 모르겠네요 다행히 이처럼 복잡한 감정을 SwiftUI의 혼합 상태 컨트롤로 표시할 수 있습니다 여러 개의 토글을 접으면 하나의 상위 토글이 됩니다 하위 토글이 각각 하나의 바인딩이라면 상위 토글은 모든 바인딩의 꾸러미로 값이 일치하지 않으면 혼합 상태로 표시됩니다
피커도 같은 방식으로 작동합니다 장식 테마 피커는 현재 선택된 장식에 따라 값이 변경됩니다 하지만 여러 장식을 선택하면 혼합 상태 인디케이터로 모든 테마를 보여줍니다 iOS 앱으로 넘어가 보죠 파티 해시태그를 선택하기 위한 버튼식 토글이 몇 개 있습니다 보더 버튼 스타일을 더해 각 토글을 구분할 수 있는데요 이러한 버튼 스타일은 버튼 모양이기만 하면 어떤 컨트롤에도 적용됩니다 토글, 메뉴, 피커 등이 모두 포함되죠 스테퍼로 넘어가 봅시다 이제 스테퍼의 값에 형식을 적용할 수 있습니다 macOS에서는 형식화된 스테퍼가 편집 가능한 영역에 값을 표시합니다 스테퍼는 watchOS에서도 사용 가능합니다 Apple Watch 스포츠는 제가 가장 좋아하는 신기능인데요 Accessibility Quick Action은 주먹을 쥐는 것으로 액션을 실행하는 대안 기능입니다 Quick Action은 여타 UI 액션처럼 정의 가능합니다 버튼 하나면 되죠 보이는 버튼과 그에 해당하는 Quick Action이 같은 코드를 사용하죠 자, 다양한 컨트롤 기능을 알아보았습니다 물론 컨트롤만이 인터랙티브에 있어 전부는 아닙니다 보다 큰 인터랙티브 컨테이너의 새로운 기능을 알아봅시다 테이블과 리스트 말이죠
이제 iPadOS에서 테이블이 지원된다는 사실을 알려드리게 되어 기쁩니다 예상하셨겠지만 iPadOS의 테이블은 macOS에서 작년에 소개한 Table API를 사용해 플랫폼 간에 코드 공유가 쉽습니다 초대 명단에 열이 세 개 있습니다 각 사람의 이름과 도시, 초대 현황이죠 iPad의 큰 화면 덕에 한꺼번에 보입니다 하지만 테이블은 iPhone처럼 작은 크기의 화면에도 적절한 크기로 조절됩니다 화면 공간이 작을 경우 첫 열만 보여지죠 컨텍스트를 바꾸어 macOS에서 확인해 봅시다 괜찮아 보이네요 컨텍스트 얘기가 나온 김에 컨텍스트 메뉴를 추가하죠 테이블 안에서 일반 액션을 수행하기 위해서입니다 여기에 사용할 기능은 새로운 선택 기반 contentMenu 변경자입니다 이 변경자는 선택 유형을 취하기 때문에 선택을 지원하는 호환 테이블이나 리스트라면 어디에나 사용 가능합니다 메뉴 편집기 안에 현재 셀렉션 꾸러미가 제공되므로 고급 컨텍스트 메뉴를 만들 수 있습니다 단일 선택 행이나 다중 선택 행은 물론 행이 선택되지 않은 경우에도 가능하죠 테이블의 빈 공간을 클릭할 때처럼 말입니다 컨텍스트 메뉴는 테이블 안에서 액션을 직접 실행하기 때문에 속도가 빠르고 효율적입니다 하지만 액션이 보다 쉽게 검색되도록 만들고 싶습니다 검색 용이성을 개선하는 좋은 방법 중 하나는 일반 액션을 툴바에 버튼으로 표시하는 겁니다 iPadOS는 새로이 개선된 툴바 디자인을 제공하여 보다 세련된 모습을 보여줍니다 iPad 툴바는 이제 사용자 커스텀은 물론 재정리 기능도 제공합니다 각각의 툴바 항목에 명시적 식별자를 제공하여 적용할 수 있으며 macOS에도 같은 API가 적용됩니다 이 식별자는 SwiftUI가 커스텀 툴바 환경 설정을 앱 시작 시에 자동으로 저장하고 복원하도록 해줍니다 참고로 iPadOS에서는 모든 툴바 항목이 커스텀 가능한 것은 아닙니다 커스텀 가능한 액션은 새로운 secondaryAction 툴바 항목 배치로 설정합니다 기본적으로 툴바 중앙에 표시되며 작은 화면의 경우 오버플로우 메뉴로 표시됩니다 좋아요! 파티를 연다는 소문이 퍼져서 참가 인원이 기하급수적으로 늘어나고 있습니다 테이블에 검색 기능을 더해서 규모를 관리해 봅시다 SwiftUI는 이미 검색 변경자를 이용해 기본 검색 기능을 지원합니다 그리고 올해부터는 검색 영역이 토큰화된 입력과 제안을 지원하여 보다 구조화된 검색어를 구축할 수 있습니다 검색 결과 필터링 개선을 위해 SwiftUI 기능에 검색 범위 지원을 추가했습니다 macOS는 툴바 아래에 범위 바가 표시되며 iOS는 내비게이션 바에 세그먼트화된 컨트롤로 표시됩니다 올해부터 SwiftUI로 iPad에서 가능해진 기능을 기본만 다뤄 봤습니다 'SwiftUI on iPad' 시리즈에서 더 많은 정보를 알아보세요 파티의 상세 사항과 물품을 더 잘 제어할 수 있게 됐으니 파티가 열린다는 소식을 퍼뜨려서 더 많은 사람을 설레게 만들어 보죠 다른 사람이나 다른 앱에 데이터를 공유하는 것은 많은 앱에서 필수적인 부분입니다 이러한 기능을 활용하면 앱 사용자들의 작업 흐름에 앱이 더욱 잘 통합될 수 있습니다 올해 새로이 개발한 기능으로 통합을 더욱 쉽게 만들었는데요 PhotosPicker부터 시작합시다 새로 개발한 다중 플랫폼 API로 사진과 영상을 골라주는 프라이버시 보호 기능도 있죠 파티라면 사진 촬영이 빠질 수 없으니까요 파티 플래너 앱에 기능을 더해 봤습니다 촬영한 사진에 재미있는 생일 축하 효과를 더해 주죠 새로운 PhotosPicker view는 앱 어디에나 배치 가능합니다 이를 활성화하면 기본 사진 선별 UI로 라이브러리에서 사진이나 영상을 선택할 수 있죠 PhotosPicker는 선택된 항목의 바인딩을 취해 실제 사진과 영상 데이터에 접근합니다
추가 환경 설정 옵션도 다양하게 보유하고 있는데요 선호하는 사진 인코딩에 따라 콘텐츠 유형을 필터링하는 것 등이 있습니다
이렇게 멋진 컵케익은 태어나서 처음 보네요 하지만 컵케익 하나로는 충분치 않죠 특수효과를 적용해 봅시다 커스텀한 사진이 준비됐으니 새로운 ShareLink API로 공유해 봅시다 각 플랫폼에는 앱에서 콘텐츠를 공유하게 해 주는 기본 인터페이스가 있습니다 watchOS 9에서는 Watch 앱 안에서 공유 시트를 표시할 수 있습니다 새로운 ShareLink view를 통해 앱 안에 시스템 공유 시트를 표시하는 겁니다 필요한 것은 공유할 콘텐츠와 공유 시트에서 사용할 미리 보기뿐입니다 이것만 있으면 자동으로 기본 공유 아이콘 버튼이 생깁니다
선택만 하면 기본 공유 시트가 표시되고 콘텐츠를 보낼 수 있습니다 공유 링크는 적용되는 컨텍스트에 반응합니다 컨텍스트 메뉴는 물론 다른 플랫폼에서도 가능하죠 PhotosPicker와 ShareLink 등은 모두 새로운 Transferable 프로토콜을 활용합니다 Swift가 최초로 선보이는 서술적 방식으로 다양한 앱에서 유형을 공유해주죠 Transferable 유형은 SwiftUI 기능을 가능케 합니다 드래그 앤 드롭처럼 말이죠 이 기능은 다른 앱의 이미지를 파티 플래너 갤러리에 쉽게 옮길 수 있습니다 새로운 dropDestination API를 사용합니다 페이로드 유형을 필요로 하는데 이 경우는 단지 이미지죠 완성 블록은 드롭 위치에서 수신된 이미지의 꾸러미를 제공합니다
스트링이나 이미지 등 대부분의 기본 유형은 이미 Transferable 유형과 호환됩니다 앱에 Transferable 유형을 기본적으로 적용했지만 여러분은 각자의 커스텀 유형에 따라 Transferable을 적용할 수 있습니다 적용 시 여러분의 적합성이 유형에 걸맞는 표시법을 나타낼 겁니다 Codable support 지원이나 커스텀 콘텐츠 유형처럼 말이죠 Transferable과 다른 표시법은 물론 고급 팁과 기술에 대해 알고 싶으시다면 'Meet Transferable'을 들어보세요 저희가 컵케익을 준비하는 동안 닉이 물품을 전부 내놓았네요 닉, 잘 돼 가요? 거의 다 됐어요! 나팔을 완전한 커스텀 배치로 늘어놓고 있었어요 하지만 시간이 더 필요하니 먼저 그래픽에 대해 이야기해 보죠 ShapeStyle에서는 올해 새로운 API를 통해 풍부한 그래픽 효과를 제공하고 있습니다 이 API를 사용해 초대장에 재미를 더해보죠! Color의 새로운 그라데이션 기능은 기존 색에 미묘한 그라데이션을 더해 줍니다 시스템 컬러와 잘 어울리네요
ShapeStyle에는 새로운 그림자 변경자도 추가됐습니다 흰색 포그라운드 스타일에 텍스트와 로고에 그림자를 더했죠 섬세한 그림자가 정말 놀랍군요 하단 그림자가 달력 로고의 모든 요소에 적용되었습니다
수많은 SF Symbol과 새로운 SwiftUI ShapeStyle 확장이라면 아주 멋진 아이콘을 만들 수 있답니다
SF Symbol 그리드를 파티에 사용해 볼 차례군요 SwiftUI Preview를 이용해 빠르게 복사하겠습니다 올해 엄청나게 개선된 기능이죠 Preview는 다중 환경에서의 view를 한 번에 편리하게 확인할 수 있는 기능입니다 저희는 올해 Xcode 14로 가변 미리 보기를 아주 쉽게 만들었습니다 이를 통해 환경 설정 코드를 쓰지 않고도 다양한 테마와 화면 크기 화면 방향에 동시에 적용되는 view를 개발할 수 있습니다 아까와 같은 그라데이션을 사용하거나 타원형 그라데이션으로 부드러운 느낌을 줄 수 있고 다크 모드와 라이트 모드 미리 보기도 가능하죠
미리 보기는 기본적으로 라이브 모드로 작동합니다 생일 파티를 하려면 춤이 빠질 수 없겠죠 그러니 SF Symbol이 춤추게 해봅시다 ♪전자 댄스 음악♪ ♪
귀여운 아이콘이 놀라운 사실을 증명해 보이는군요 SwiftUI 텍스트와 이미지 애니메이션의 우수성을 말이죠 텍스트 애니메이션을 천천히 재생해 보죠 텍스트도 두께, 스타일 레이아웃을 애니메이션화 할 수 있습니다 가장 훌륭한 점이 남았는데요 SwiftUI 전체에 적용되는 애니메이션 API와 같습니다 제가 UI 프로그래밍에서 가장 좋아하는 부분으로 마무리 지어 보죠 바로 응용 기하학입니다 혹은 레이아웃이라고 부릅니다 SwiftUI는 새로운 방식의 view 레이아웃을 제공합니다 그리드는 2차원 그리드로 view를 정렬하는 새로운 컨테이너 view입니다 그리드는 subview를 먼저 측정하여 다중열 셀과 행렬의 자동 정렬을 가능하게 합니다 사실 아까 그리드를 보셨는데요
그리드와 GridRow gridCellColumns 변경자로 그리드 피스밀을 구축할 수 있습니다 물론 SwiftUI의 다른 레이아웃처럼 그리드도 구성에 사용할 수 있습니다 SwiftUI의 레이아웃 모델을 처음 공개하면서 초기 레이아웃 유형 Toolbox를 제공했습니다 가장 일반적인 레이아웃을 구현하기 위해서죠 대개 초기 레이아웃 유형으로도 작업이 가능하지만 가끔은 명령형 레이아웃 코드가 필요합니다 크기와 minX frame.origin.x minus frame.midX/2+3 이런 경우가 생기면 새로운 레이아웃 프로토콜을 사용해야 합니다 SwiftUI의 스택과 그리드를 적용할 때의 기능과 유연성을 온전히 활용하여 여러분만의 최고급 레이아웃 도안을 구축할 수 있습니다 레이아웃을 사용해서 생일파티 손님들의 맞춤 좌석 차트를 만들었습니다 일렬로 앉힐까요? 아니면 조별로 앉힐까요? 레이아웃 기능과 함께라면 선택할 필요가 없습니다 레이아웃 프로토콜을 사용하면 특별한 목적을 위한 레이아웃을 효과적으로 사용하여 여러분의 view 체계에 맞출 수 있습니다 레이아웃 적용 방법과 새롭고 멋진 레이아웃 기술을 배우고 싶다면 'Compose custom layouts with SwiftUI'를 들어보세요 여러분을 위한 레이아웃 세션을 준비해 두었습니다 새로운 AnyLayout 유형을 이용하면 그리드 레이아웃과 산발 레이아웃을 직접 전환하며 사용 가능합니다 이번 세션이 거의 끝나가네요 깜짝 발표가 하나 남았습니다 여러분을 초대합니다! ♪ SwiftUI와 새로운 API의 생일파티에 여러분 모두를 초대합니다 API의 상세 사항은 아직 더 많이 남아 있습니다 시간이 모자라서 언급하지 못한 API도 있으니까요 파티와 WWDC 2022를 즐기세요 저희는 케이크를 즐겨 보겠습니다 ♪
-
-
2:51 - Swift Charts: Required models and extensions
import Foundation import SwiftUI // MARK: - Party Planner Models enum PartyTask: String, Identifiable, CaseIterable, Hashable { case food = "Food" case music = "Music" case supplies = "Supplies" case invitations = "Invitations" case eventDetails = "Event Details" case activities = "Activities" case funProjection = "Fun Projection" case vips = "VIPs" case photosFilter = "Photos Filter" var name: String { rawValue } var color: Color { switch self { case .food: return palette[0] case .supplies: return palette[1] case .invitations: return palette[2] case .eventDetails: return palette[3] case .funProjection: return palette[4] case .activities: return palette[5] case .vips: return palette[6] case .music: return palette[7] case .photosFilter: return palette[8] } } var imageName: String { switch self { case .food: return "birthday.cake" case .supplies: return "party.popper" case .invitations: return "envelope.open" case .eventDetails: return "calendar.badge.clock" case .funProjection: return "gauge.medium" case .activities: return "bubbles.and.sparkles" case .vips: return "person.2" case .music: return "music.mic" case .photosFilter: return "camera.filters" } } var id: String { rawValue } var subtitle: String { switch self { case .food: return "Apps, 'Zerts and Cakes" case .supplies: return "Streamers, Plates, Cups" case .invitations: return "Sendable, Non-Transferable" case .eventDetails: return "Date, Duration, And Placement" case .funProjection: return "Beta — How Fun Will Your Party Be?" case .activities: return "Dancing, Paired Programing" case .vips: return "User Interactive Guests" case .music: return "Song Requests & Karaoke" case .photosFilter: return "Filtering and Mapping" } } var emoji: String { switch self { case .food: return "🎂" case .music: return "🎤" case .supplies: return "🎉" case .invitations: return "📨" case .eventDetails: return "🗓" case .funProjection: return "🧭" case .activities: return "💃" case .vips: return "⭐️" case .photosFilter: return "📸" } } } private let palette: [Color] = [ Color(red: 0.73, green: 0.20, blue: 0.20), Color(red: 0.95, green: 0.66, blue: 0.24), Color(red: 0.14, green: 0.29, blue: 0.49), Color(red: 0.46, green: 0.76, blue: 0.67), Color(red: 0.30, green: 0.33, blue: 0.22), Color(red: 0.49, green: 0.55, blue: 0.64), Color(red: 0.92, green: 0.53, blue: 0.30), Color(red: 0.20, green: 0.45, blue: 0.55), Color(red: 0.41, green: 0.45, blue: 0.45), Color(red: 0.87, green: 0.67, blue: 0.61) ] // MARK: - Swift Charts Models struct RemainingPartyTask: Identifiable { let category: PartyTask let date: Date let remainingCount: Int let id = UUID() } let remainingSupplies: [RemainingPartyTask] = [ RemainingPartyTask(category: .supplies, date: .daysAgo(4), remainingCount: 10), RemainingPartyTask(category: .supplies, date: .daysAgo(3), remainingCount: 11), RemainingPartyTask(category: .supplies, date: .daysAgo(2), remainingCount: 9), RemainingPartyTask(category: .supplies, date: .daysAgo(1), remainingCount: 4), RemainingPartyTask(category: .supplies, date: .daysAgo(0), remainingCount: 1), ] let remainingInvitations: [RemainingPartyTask] = [ RemainingPartyTask(category: .invitations, date: .daysAgo(4), remainingCount: 14), RemainingPartyTask(category: .invitations, date: .daysAgo(3), remainingCount: 13), RemainingPartyTask(category: .invitations, date: .daysAgo(2), remainingCount: 11), RemainingPartyTask(category: .invitations, date: .daysAgo(1), remainingCount: 6), RemainingPartyTask(category: .invitations, date: .daysAgo(0), remainingCount: 4), ] let remainingActivities: [RemainingPartyTask] = [ RemainingPartyTask(category: .activities, date: .daysAgo(4), remainingCount: 6), RemainingPartyTask(category: .activities, date: .daysAgo(3), remainingCount: 7), RemainingPartyTask(category: .activities, date: .daysAgo(2), remainingCount: 4), RemainingPartyTask(category: .activities, date: .daysAgo(1), remainingCount: 2), RemainingPartyTask(category: .activities, date: .daysAgo(0), remainingCount: 1), ] let remainingVenue: [RemainingPartyTask] = [ RemainingPartyTask(category: .eventDetails, date: .daysAgo(4), remainingCount: 4), RemainingPartyTask(category: .eventDetails, date: .daysAgo(3), remainingCount: 5), RemainingPartyTask(category: .eventDetails, date: .daysAgo(2), remainingCount: 7), RemainingPartyTask(category: .eventDetails, date: .daysAgo(1), remainingCount: 4), RemainingPartyTask(category: .eventDetails, date: .daysAgo(0), remainingCount: 2) ] let partyTasksRemaining: [RemainingPartyTask] = [remainingVenue, remainingActivities, remainingInvitations, remainingSupplies ].flatMap { $0 } // MARK: Date Utilities extension Date { static func daysAgo(_ daysAgo: Int) -> Date { Calendar.current.date(byAdding: .day, value: -daysAgo, to: Date())! } func daysEqual(_ other: Date) -> Bool { Calendar.current.dateComponents([.day], from: self, to: other).day == 0 } } extension Date { static let wwdc22: Date = DateComponents( calendar: .autoupdatingCurrent, timeZone: TimeZone(identifier: "PST"), year: 2022, month: 6, day: 6, hour: 9, minute: 41, second: 00).date! }
-
2:56 - Swift Charts: Bar Chart 1
Chart(partyTasksRemaining) { BarMark( x: .value("Date", $0.date, unit: .day), y: .value("Tasks Remaining", $0.remainingCount) ) } .padding()
-
3:33 - Swift Charts: Bar chart 2
var body: some View { Chart(partyTasksRemaining) { BarMark( x: .value("Date", $0.date, unit: .day), y: .value("Tasks Remaining", $0.remainingCount) ) } .padding() }
-
3:53 - Swift Charts: LineMark
var body: some View { Chart(partyTasksRemaining) { LineMark( x: .value("Date", $0.date, unit: .day), y: .value("Tasks Remaining", $0.remainingCount) ) .foregroundStyle(by: .value("Category", $0.category)) } .padding() }
-
4:08 - Swift Charts: Line Chart with Symbols
var body: some View { Chart(partyTasksRemaining) { LineMark( x: .value("Date", $0.date, unit: .day), y: .value("Tasks Remaining", $0.remainingCount) ) .foregroundStyle(by: .value("Category", $0.category)) .symbol(by: .value("Category", $0.category)) } .padding() }
-
4:39 - Swift Charts: Annotations
var body: some View { Chart { ForEach(partyTasksRemaining) { task in LineMark( x: .value("Date", task.date, unit: .day), y: .value("Tasks Remaining", task.remainingCount) ) .foregroundStyle(by: .value("Category", task.category)) .symbol(by: .value("Category", task.category)) .annotation(position: .leading) { Text("\(task.category.emoji)") } } RuleMark(y: .value("Value", 5)) .foregroundStyle(.red) .lineStyle(StrokeStyle(lineWidth: 2.0, dash: [4, 5])) .annotation(position: .top, alignment: .trailing) { VStack(alignment: .trailing) { Text("Today's Goal") Text("Status: ✔︎") } .font(.caption) .foregroundColor(.gray) .padding(.trailing, 2) } } }
-
6:15 - Food Models
import Foundation // MARK: Food Models /// A model representing a food with a price and quantity. struct FoodItem: Hashable, Identifiable, Codable, Equatable { let emoji: String let name: String var description: String = "" let price: Decimal var quantity: Int = 0 var id: String { name } } let donut = FoodItem(emoji: "🍩", name: "Doughnut", description: "Yeast, Old-fashioned, Cake, and the dubious Apple Fritter", price: 2.35, quantity: 6) let moonCake = FoodItem(emoji: "🥮", name: "Moon Cake", description: "Lotus seed paste — plenty of crust", price: 2.20, quantity: 4) let shavedIce = FoodItem(emoji: "🍧", name: "Shaved Ice", description: "Shave your own ice!", price: 3.25, quantity: 1) let cupcake = FoodItem(emoji: "🧁", name: "Cupcake", description: "Also goes by the name Cake Nano", price: 4.00, quantity: 5) let flan = FoodItem(emoji: "🍮", name: "Flan", description: "What's in a flan? That which we call milk, eggs, and sugar by any other name would taste just as sweet.", price: 6.50, quantity: 2) let taffy = FoodItem(emoji: "🍬", name: "Taffy", description: "Freshwater, actually.", price: 1.00, quantity: 11) let cake = FoodItem(emoji: "🎂", name: "Cake Cake", description: "The real deal", price: 15.00, quantity: 1) let cookie = FoodItem(emoji: "🍪", name: "Cookie Cake", description: "The ultimate dessert", price: 4.30, quantity: 1) let relatedFoods = [donut, moonCake, shavedIce, cupcake, flan, taffy, cake, cookie] extension Array where Element: Equatable { /// A quick-and-dirty way of getting a random few elements from an Array that don't include a single, /// particular element. /// - Parameters: /// - count: The number of desired random elements, must be less than `Array.count` /// - except: Filter out this particular element func random(_ count: Int, except: Element) -> [Element] { assert(count >= count) var copy = self copy.shuffle() copy.removeAll(where: { $0 == except }) return Array(copy[0..<count]) } } let partyFoods = [ FoodItem(emoji: "🍨", name: "Ice Cream", price: 3.50, quantity: 4), flan, taffy, donut, FoodItem(emoji: "🍉", name: "Watermelon", price: 3.65, quantity: 1), FoodItem(emoji: "🍒", name: "Cherries", price: 8.00, quantity: 1), cupcake, cookie, FoodItem(emoji: "🍥", name: "Fish Cake", price: 5.00, quantity: 2), moonCake, cake, FoodItem(emoji: "🍘", name: "Rice Cracker", price: 0.25, quantity: 16), FoodItem(emoji: "🥨", name: "Pretzels", price: 3.00, quantity: 3), shavedIce, FoodItem(emoji: "🥧", name: "Apple Pie", price: 4.10, quantity: 1) ]
-
6:21 - NavigationStack with view-based NavigationLinks
// MARK: NavigationStack with View-based NavigationLinks struct FoodsListView: View { fileprivate var foodItems = partyFoods @State private var selectedFoodItems: [FoodItem] = [] var body: some View { NavigationStack { List(foodItems) { item in NavigationLink { FoodDetailView(item: item) } label: { FoodRow(food: item) } } .navigationTitle("Party Food") } } } struct FoodRow: View { let food: FoodItem var body: some View { HStack { Text(food.emoji) .font(.system(size: 15)) .foregroundStyle(.secondary) Text(food.name) .font(.caption) .bold() Spacer() Text("\(food.quantity)") } } } struct FoodDetailView: View { let item: FoodItem var body: some View { ScrollView { VStack { HStack { Text(item.emoji) .font(.system(size: 30)) Text(item.name) .font(.title3) } .padding(.bottom, 4) Text(item.description) .font(.caption) Divider() RelatedFoodsView(relatedFoods: relatedFoods.random(3, except: item)) } } } } struct RelatedFoodsView: View { @State var relatedFoods: [FoodItem] var body: some View { VStack { Text("Related Foods") .background(.background, in: RoundedRectangle(cornerRadius: 2)) HStack { ForEach(relatedFoods) { food in NavigationLink { FoodDetailView(item: food) } label: { Text(food.emoji) } } } } } }
-
6:51 - NavigationStack with value-based NavigationLinks
// MARK: NavigationStack with Value-based Navigation Links struct FoodsListView: View { fileprivate var foodItems = partyFoods @State private var selectedFoodItems: [FoodItem] = [] var body: some View { NavigationStack(path: $selectedFoodItems) { List(foodItems) { item in NavigationLink(value: item) { FoodRow(food: item) } } .navigationTitle("Party Food") .navigationDestination(for: FoodItem.self) { item in FoodDetailView(item: item, path: $selectedFoodItems) } } } } struct FoodDetailView: View { let item: FoodItem @Binding var path: [FoodItem] var body: some View { ScrollView { VStack { HStack { Text(item.emoji) .font(.system(size: 30)) Text(item.name) .font(.title3) } .padding(.bottom, 4) Text(item.description) .font(.caption) Divider() RelatedFoodsView(relatedFoods: relatedFoods.random(3, except: item)) if path.count > 1 { Button("Back to First Item") { path.removeSubrange(1...) } } } } } } struct RelatedFoodsView: View { @State var relatedFoods: [FoodItem] var body: some View { VStack { Text("Related Foods") .background(.background, in: RoundedRectangle(cornerRadius: 2)) HStack { ForEach(relatedFoods) { food in NavigationLink(value: food) { Text(food.emoji) } } } } } }
-
8:16 - NavigationSplitView
// MARK: NavigationSplitView Demo struct PartyPlannerHome: View { @State private var selectedTask: PartyTask? var body: some View { NavigationSplitView { List(PartyTask.allCases, selection: $selectedTask) { task in NavigationLink(value: task) { TaskLabel(task: task) } .listItemTint(task.color) } } detail: { selectedTask.flatMap { $0.color } ?? .white } } } struct TaskLabel: View { let task: PartyTask var body: some View { Label { VStack(alignment: .leading) { Text(task.name) Text(task.subtitle) .font(.footnote) .foregroundStyle(.secondary) } } icon: { Image(systemName: task.imageName) .symbolVariant(.circle.fill) } } }
-
9:13 - Navigation split and stack composition
struct PartyPlannerHome: View { @State private var selectedTask: PartyTask? var body: some View { NavigationSplitView { List(PartyTask.allCases, selection: $selectedTask) { task in NavigationLink(value: task) { TaskLabel(task: task) } .listItemTint(task.color) } } detail: { if case .food = selectedTask { FoodsListView() } else { selectedTask.flatMap { $0.color } ?? .white } } } }
-
10:10 - Window
@main struct PartyPlanner: App { var body: some Scene { WindowGroup("Party Planner") { PartyPlannerHome() } Window("Party Budget", id: "budget") { Text("Budget View") } .keyboardShortcut("0") } }
-
10:42 - Open window
struct DetailView: View { @Environment(\.openWindow) var openWindow var body: some View { Text("Detail View") .toolbar { Button { openWindow(id: "budget") } label: { Image(systemName: "dollarsign") } } } }
-
11:00 - Window customizations
@main struct PartyPlanner: App { var body: some Scene { WindowGroup("Party Planner") { PartyPlannerHome() } Window("Party Budget", id: "budget") { Text("Budget View") } .keyboardShortcut("0") .defaultPosition(.topLeading) .defaultSize(width: 220, height: 250) } }
-
11:47 - Resizable sheets
struct PartyPlannerHome: View { @State private var selectedTask: PartyTask? @State private var presented: Bool = false var body: some View { NavigationSplitView { List(PartyTask.allCases, selection: $selectedTask) { task in NavigationLink(value: task) { TaskLabel(task: task) } .listItemTint(task.color) } } detail: { if case .food = selectedTask { FoodsListView() } else { selectedTask.flatMap { $0.color } ?? .white } } .sheet(isPresented: $presented) { Text("Budget View") .presentationDetents([.height(250), .medium]) .presentationDragIndicator(.visible) } } }
-
12:51 - Menu bar extras
@main struct PartyPlanner: App { var body: some Scene { Window("Party Budget", id: "budget") { Text("Budget View") } MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") { BulletinBoard() } .menuBarExtraStyle(.window) } } private let allPosts: [String] = [ "Did you know: On your third birthday, you are celebrating your 4.0 release.", ] struct BulletinBoard: View { @State var currentPostIndex: Int = 0 var currentPost: String { allPosts[currentPostIndex] } var body: some View { VStack(spacing: 16) { VStack(spacing: 12) { HStack(alignment: .firstTextBaseline) { Text("“") .font(.custom("Helvetica", size: 50).bold()) .baselineOffset(-23) .foregroundStyle(.tertiary) Text("Party Bulletin Board") .font(.headline.weight(.semibold)) .foregroundStyle(.secondary) Spacer() Text("June 6, 2022") .font(.headline.weight(.regular)) .foregroundStyle(.secondary) } .frame(height: 20) Text(currentPost) .font(.system(size: 18)) .multilineTextAlignment(.center) } .padding(.bottom, 4) Divider() HStack { Button { } label: { Label("Calendar", systemImage: "calendar") } Button { currentPostIndex = (currentPostIndex + 1) % allPosts.count } label: { Text("Previous") .frame(maxWidth: .infinity) } ShareLink(items: [currentPost]) } .labelStyle(.iconOnly) .controlSize(.large) } .padding(16) } }
-
12:58 - Menu bar extra app
@main struct MessageBoard: App { var body: some Scene { MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") { BulletinBoard() } .menuBarExtraStyle(.window) } }
-
14:25 - Grouped forms
struct ContentView: View { enum Theme: String, CaseIterable, Identifiable { var id: String { self.rawValue } case blue, gold, black, white var swatch: some View { Circle() .fill(color) .overlay { Circle().stroke(.tertiary) } .frame(width: 15, height: 15) } var color: Color { switch self { case .blue: return .blue case .gold: return .yellow case .black: return .black case .white: return .white } } } enum ColorScheme: String { case light, dark } enum Decoration: String, CaseIterable { case balloon, confetti, inflatables, noisemakers, all, none } private let address = "One Apple Park Way" @State private var date: Date = DateComponents( calendar: .current, timeZone: .current, year: 2022, month: 6, day: 6 ).date! @State private var eventDescription: String = "Come and join us celebrate SwiftUI's birthday party!\n🎉🎂" @State private var scheme: ColorScheme = .light @State private var accent: Theme = .blue @State private var extraGuests = false @State private var spacesCount: Float = 2 @State private var includeBalloons = false @State private var includeConfetti = false @State private var includeInflatables = false @State private var includeBlowers = false @State private var selectedDecorations: [Decoration] = [] @State private var decorationThemes: [Decoration: Theme] = [ .balloon : .blue, .confetti: .gold, .inflatables: .black, .noisemakers: .white, .none: .black ] private var themes: [Binding<Theme>] { if selectedDecorations.count == 0 { return [Binding($decorationThemes[.none])!] } return selectedDecorations.compactMap { Binding($decorationThemes[$0]) } } var body: some View { Form { Section { LabeledContent("Location", value: address) DatePicker("Date", selection: $date) TextField("Description", text: $eventDescription, axis: .vertical) .lineLimit(3, reservesSpace: true) } Section("Vibe") { Picker("Accent color", selection: $accent) { ForEach(Theme.allCases) { theme in Text(theme.rawValue.capitalized).tag(theme) } } Picker("Color scheme", selection: $scheme) { Text("Light").tag(ColorScheme.light) Text("Dark").tag(ColorScheme.dark) } #if os(macOS) .pickerStyle(.inline) #endif Toggle(isOn: $extraGuests) { Text("Allow extra guests") Text("The more the merrier!") } if extraGuests { Stepper("Guests limit", value: $spacesCount, format: .number) } } Section("Decorations") { Section { List(selection: $selectedDecorations) { DisclosureGroup { HStack { Toggle("Balloons 🎈", isOn: $includeBalloons) Spacer() decorationThemes[.balloon].map { $0.swatch } } .tag(Decoration.balloon) HStack { Toggle("Confetti 🎊", isOn: $includeConfetti) Spacer() decorationThemes[.confetti].map { $0.swatch } } .tag(Decoration.confetti) HStack { Toggle("Inflatables 🪅", isOn: $includeInflatables) Spacer() decorationThemes[.inflatables].map { $0.swatch } } .tag(Decoration.inflatables) HStack { Toggle("Party Horns 🥳", isOn: $includeBlowers) Spacer() decorationThemes[.noisemakers].map { $0.swatch } } .tag(Decoration.noisemakers) } label: { Toggle("All Decorations", isOn: [ $includeBalloons, $includeConfetti, $includeInflatables, $includeBlowers ]) .tag(Decoration.all) } #if os(macOS) .toggleStyle(.checkbox) #endif } Picker("Decoration theme", selection: themes) { Text("Blue").tag(Theme.blue) Text("Black").tag(Theme.black) Text("Gold").tag(Theme.gold) Text("White").tag(Theme.white) } #if os(macOS) .pickerStyle(.radioGroup) #endif } } } .formStyle(.grouped) } }
-
15:45 - Grouped forms with LabeledContent wrapping a view.
struct ContentView: View { enum Theme: String, CaseIterable, Identifiable { var id: String { self.rawValue } case blue, gold, black, white var swatch: some View { Circle() .fill(color) .overlay { Circle().stroke(.tertiary) } .frame(width: 15, height: 15) } var color: Color { switch self { case .blue: return .blue case .gold: return .yellow case .black: return .black case .white: return .white } } } enum ColorScheme: String { case light, dark } enum Decoration: String, CaseIterable { case balloon, confetti, inflatables, noisemakers, all, none } private let location = Location( firstLine: "One Apple Park Way", secondLine: "Cupertino, CA 95014") @State private var date: Date = DateComponents( calendar: .current, timeZone: .current, year: 2022, month: 6, day: 6 ).date! @State private var eventDescription: String = "Come and join us celebrate SwiftUI's birthday party!\n🎉🎂" @State private var scheme: ColorScheme = .light @State private var accent: Theme = .blue @State private var extraGuests = false @State private var spacesCount: Float = 2 @State private var includeBalloons = false @State private var includeConfetti = false @State private var includeInflatables = false @State private var includeBlowers = false @State private var selectedDecorations: [Decoration] = [] @State private var decorationThemes: [Decoration: Theme] = [ .balloon : .blue, .confetti: .gold, .inflatables: .black, .noisemakers: .white, .none: .black ] private var themes: [Binding<Theme>] { if selectedDecorations.count == 0 { return [Binding($decorationThemes[.none])!] } return selectedDecorations.compactMap { Binding($decorationThemes[$0]) } } var body: some View { Form { Section { LabeledContent("Location") { AddressView(location) } DatePicker("Date", selection: $date) TextField("Description", text: $eventDescription, axis: .vertical) .lineLimit(3, reservesSpace: true) } Section("Vibe") { Picker("Accent color", selection: $accent) { ForEach(Theme.allCases) { accent in Text(accent.rawValue.capitalized).tag(accent) } } Picker("Color scheme", selection: $scheme) { Text("Light").tag(ColorScheme.light) Text("Dark").tag(ColorScheme.dark) } #if os(macOS) .pickerStyle(.inline) #endif Toggle(isOn: $extraGuests) { Text("Allow extra guests") Text("The more the merrier!") } if extraGuests { Stepper("Guests limit", value: $spacesCount, format: .number) } } Section("Decorations") { Section { List(selection: $selectedDecorations) { DisclosureGroup { HStack { Toggle("Balloons 🎈", isOn: $includeBalloons) Spacer() decorationThemes[.balloon].map { $0.swatch } } .tag(Decoration.balloon) HStack { Toggle("Confetti 🎊", isOn: $includeConfetti) Spacer() decorationThemes[.confetti].map { $0.swatch } } .tag(Decoration.confetti) HStack { Toggle("Inflatables 🪅", isOn: $includeInflatables) Spacer() decorationThemes[.inflatables].map { $0.swatch } } .tag(Decoration.inflatables) HStack { Toggle("Party Horns 🥳", isOn: $includeBlowers) Spacer() decorationThemes[.noisemakers].map { $0.swatch } } .tag(Decoration.noisemakers) } label: { Toggle("All Decorations", isOn: [ $includeBalloons, $includeConfetti, $includeInflatables, $includeBlowers ]) .tag(Decoration.all) } #if os(macOS) .toggleStyle(.checkbox) #endif } Picker("Decoration theme", selection: themes) { Text("Blue").tag(Theme.blue) Text("Black").tag(Theme.black) Text("Gold").tag(Theme.gold) Text("White").tag(Theme.white) } #if os(macOS) .pickerStyle(.radioGroup) #endif } } } .formStyle(.grouped) } } struct AddressView: View { private let location: Location init(_ location: Location) { self.location = location } var body: some View { VStack { Text(location.firstLine) Text(location.secondLine) } } } struct Location { let firstLine: String let secondLine: String }
-
17:06 - Multiline text fields
struct ContentView: View { @State private var activityDates: Set<DateComponents> = [ DateComponents(calendar: .current, year: 2022, month: 6, day: 6), DateComponents(calendar: .current, year: 2022, month: 6, day: 9), DateComponents(calendar: .current, year: 2022, month: 6, day: 10) ] @State private var title: String = .init() @State private var description: String = """ Join us, and let's force unwrap SwiftUl's birthday presents. Note that although this activity is optional, we may have guards at the entry. """ var body: some View { NavigationStack { Form { Section { TextField("Title", text: $title) TextField("Description", text: $description, axis: .vertical) } Section("Dates") { MultiDatePicker("Activities Dates", selection: $activityDates) } } .navigationTitle("New Activity") .toolbar { Button("Save") {} } } } }
-
17:20 - Multiline text fields with line limit
struct ContentView: View { @State private var activityDates: Set<DateComponents> = [ DateComponents(calendar: .current, year: 2022, month: 6, day: 6), DateComponents(calendar: .current, year: 2022, month: 6, day: 9), DateComponents(calendar: .current, year: 2022, month: 6, day: 10) ] @State private var title: String = .init() @State private var description: String = """ Join us, and let's force unwrap SwiftUl's birthday presents. Note that although this activity is optional, we may have guards at the entry. """ var body: some View { NavigationStack { Form { Section { TextField("Title", text: $title) TextField("Description", text: $description, axis: .vertical) .lineLimit(5) } Section("Dates") { MultiDatePicker("Activities Dates", selection: $activityDates) } } .navigationTitle("New Activity") .toolbar { Button("Save") {} } } } }
-
17:23 - Multiline text fields with line limit range
struct ContentView: View { @State private var activityDates: Set<DateComponents> = [ DateComponents(calendar: .current, year: 2022, month: 6, day: 6), DateComponents(calendar: .current, year: 2022, month: 6, day: 9), DateComponents(calendar: .current, year: 2022, month: 6, day: 10) ] @State private var title: String = .init() @State private var description: String = """ Join us, and let's force unwrap SwiftUl's birthday presents. Note that although this activity is optional, we may have guards at the entry. """ var body: some View { NavigationStack { Form { Section { TextField("Title", text: $title) TextField("Description", text: $description, axis: .vertical) .lineLimit(5...10) } Section("Dates") { MultiDatePicker("Activities Dates", selection: $activityDates) } } .navigationTitle("New Activity") .toolbar { Button("Save") {} } } } }
-
17:40 - MultiDatePicker
struct ContentView: View { @State private var activityDates: Set<DateComponents> = [ DateComponents(calendar: .current, year: 2022, month: 6, day: 6), DateComponents(calendar: .current, year: 2022, month: 6, day: 9), DateComponents(calendar: .current, year: 2022, month: 6, day: 10) ] @State private var title: String = .init() @State private var description: String = """ Join us, and let's force unwrap SwiftUl's birthday presents. Note that although this activity is optional, we may have guards at the entry. """ var body: some View { NavigationStack { Form { Section { TextField("Title", text: $title) TextField("Description", text: $description, axis: .vertical) } Section("Dates") { MultiDatePicker("Activities Dates", selection: $activityDates) } } .navigationTitle("New Activity") .toolbar { Button("Save") {} } } } }
-
18:10 - Mixed-state toggles & pickers
struct ContentView: View { enum Theme: String, CaseIterable, Identifiable { var id: String { self.rawValue } case blue, gold, black, white var swatch: some View { Circle() .fill(color) .overlay { Circle().stroke(.tertiary) } .frame(width: 15, height: 15) } var color: Color { switch self { case .blue: return .blue case .gold: return .yellow case .black: return .black case .white: return .white } } } enum ColorScheme: String { case light, dark } enum Decoration: String, CaseIterable { case balloon, confetti, inflatables, noisemakers, all, none } private let location = Location( firstLine: "One Apple Park Way", secondLine: "Cupertino, CA 95014") @State private var date: Date = DateComponents( calendar: .current, timeZone: .current, year: 2022, month: 6, day: 6 ).date! @State private var eventDescription: String = "Come and join us celebrate SwiftUI's birthday party!\n🎉🎂" @State private var scheme: ColorScheme = .light @State private var accent: Theme = .blue @State private var extraGuests = false @State private var spacesCount: Float = 2 @State private var includeBalloons = false @State private var includeConfetti = false @State private var includeInflatables = false @State private var includeBlowers = false @State private var selectedDecorations: [Decoration] = [] @State private var decorationThemes: [Decoration: Theme] = [ .balloon : .blue, .confetti: .gold, .inflatables: .black, .noisemakers: .white, .none: .black ] private var themes: [Binding<Theme>] { if selectedDecorations.count == 0 { return [Binding($decorationThemes[.none])!] } return selectedDecorations.compactMap { Binding($decorationThemes[$0]) } } var body: some View { Form { Section { LabeledContent("Location") { AddressView(location) } DatePicker("Date", selection: $date) TextField("Description", text: $eventDescription, axis: .vertical) .lineLimit(3, reservesSpace: true) } Section("Vibe") { Picker("Accent color", selection: $accent) { ForEach(Theme.allCases) { accent in Text(accent.rawValue.capitalized).tag(accent) } } Picker("Color scheme", selection: $scheme) { Text("Light").tag(ColorScheme.light) Text("Dark").tag(ColorScheme.dark) } #if os(macOS) .pickerStyle(.inline) #endif Toggle(isOn: $extraGuests) { Text("Allow extra guests") Text("The more the merrier!") } if extraGuests { Stepper("Guests limit", value: $spacesCount, format: .number) } } Section("Decorations") { Section { List(selection: $selectedDecorations) { DisclosureGroup { HStack { Toggle("Balloons 🎈", isOn: $includeBalloons) Spacer() decorationThemes[.balloon].map { $0.swatch } } .tag(Decoration.balloon) HStack { Toggle("Confetti 🎊", isOn: $includeConfetti) Spacer() decorationThemes[.confetti].map { $0.swatch } } .tag(Decoration.confetti) HStack { Toggle("Inflatables 🪅", isOn: $includeInflatables) Spacer() decorationThemes[.inflatables].map { $0.swatch } } .tag(Decoration.inflatables) HStack { Toggle("Party Horns 🥳", isOn: $includeBlowers) Spacer() decorationThemes[.noisemakers].map { $0.swatch } } .tag(Decoration.noisemakers) } label: { Toggle("All Decorations", isOn: [ $includeBalloons, $includeConfetti, $includeInflatables, $includeBlowers ]) .tag(Decoration.all) } #if os(macOS) .toggleStyle(.checkbox) #endif } Picker("Decoration theme", selection: themes) { Text("Blue").tag(Theme.blue) Text("Black").tag(Theme.black) Text("Gold").tag(Theme.gold) Text("White").tag(Theme.white) } #if os(macOS) .pickerStyle(.radioGroup) #endif } } } .formStyle(.grouped) } } struct AddressView: View { private let location: Location init(_ location: Location) { self.location = location } var body: some View { VStack { Text(location.firstLine) Text(location.secondLine) } } } struct Location { let firstLine: String let secondLine: String }
-
18:53 - ButtonStyle composition & Steppers
struct ContentView: View { enum Theme: String, CaseIterable, Identifiable { var id: String { self.rawValue } case blue, gold, black, white var swatch: some View { Circle() .fill(color) .overlay { Circle().stroke(.tertiary) } .frame(width: 15, height: 15) } var color: Color { switch self { case .blue: return .blue case .gold: return .yellow case .black: return .black case .white: return .white } } } enum ColorScheme: String { case light, dark } enum Decoration: String, CaseIterable { case balloon, confetti, inflatables, noisemakers, all, none } private let location = Location( firstLine: "One Apple Park Way", secondLine: "Cupertino, CA 95014") @State private var date: Date = DateComponents( calendar: .current, timeZone: .current, year: 2022, month: 6, day: 6 ).date! @State private var eventDescription: String = "Come and join us celebrate SwiftUI's birthday party!\n🎉🎂" @State private var scheme: ColorScheme = .light @State private var accent: Theme = .blue @State private var extraGuests = false @State private var spacesCount: Float = 2 @State private var includeBalloons = false @State private var includeConfetti = false @State private var includeInflatables = false @State private var includeBlowers = false @State private var swiftastic = false @State private var wwdcParty = true @State private var offTheCharts = true @State private var oneMoreThing = false @State private var selectedDecorations: [Decoration] = [] @State private var decorationThemes: [Decoration: Theme] = [ .balloon : .blue, .confetti: .gold, .inflatables: .black, .noisemakers: .white, .none: .black ] private var themes: [Binding<Theme>] { if selectedDecorations.count == 0 { return [Binding($decorationThemes[.none])!] } return selectedDecorations.compactMap { Binding($decorationThemes[$0]) } } var body: some View { Form { Section { LabeledContent("Location") { AddressView(location) } DatePicker("Date", selection: $date) TextField("Description", text: $eventDescription, axis: .vertical) .lineLimit(3, reservesSpace: true) } Section("Vibe") { Picker("Accent color", selection: $accent) { ForEach(Theme.allCases) { accent in Text(accent.rawValue.capitalized).tag(accent) } } Picker("Color scheme", selection: $scheme) { Text("Light").tag(ColorScheme.light) Text("Dark").tag(ColorScheme.dark) } #if os(macOS) .pickerStyle(.inline) #endif Toggle(isOn: $extraGuests) { Text("Allow extra guests") Text("The more the merrier!") } if extraGuests { Stepper("Guests limit", value: $spacesCount, format: .number) } } Section("Decorations") { Section { List { DisclosureGroup { HStack { Toggle("Balloons 🎈", isOn: $includeBalloons) Spacer() decorationThemes[.balloon].map { $0.swatch } } .tag(Decoration.balloon) HStack { Toggle("Confetti 🎊", isOn: $includeConfetti) Spacer() decorationThemes[.confetti].map { $0.swatch } } .tag(Decoration.confetti) HStack { Toggle("Inflatables 🪅", isOn: $includeInflatables) Spacer() decorationThemes[.inflatables].map { $0.swatch } } .tag(Decoration.inflatables) HStack { Toggle("Party Horns 🥳", isOn: $includeBlowers) Spacer() decorationThemes[.noisemakers].map { $0.swatch } } .tag(Decoration.noisemakers) } label: { Toggle("All Decorations", isOn: [ $includeBalloons, $includeConfetti, $includeInflatables, $includeBlowers ]) .tag(Decoration.all) } #if os(macOS) .toggleStyle(.checkbox) #endif } Picker("Decoration theme", selection: themes) { Text("Blue").tag(Theme.blue) Text("Black").tag(Theme.black) Text("Gold").tag(Theme.gold) Text("White").tag(Theme.white) } #if os(macOS) .pickerStyle(.radioGroup) #endif } } Section("Hashtags") { VStack(alignment: .leading) { HStack { Toggle("#Swiftastic", isOn: $swiftastic) Toggle("#WWParty", isOn: $wwdcParty) } HStack { Toggle("#OffTheCharts", isOn: $offTheCharts) Toggle("#OneMoreThing", isOn: $oneMoreThing) } } .toggleStyle(.button) .buttonStyle(.bordered) } } .formStyle(.grouped) } } struct AddressView: View { private let location: Location init(_ location: Location) { self.location = location } var body: some View { VStack { Text(location.firstLine) Text(location.secondLine) } } } struct Location { let firstLine: String let secondLine: String }
-
19:33 - Accessibility Quick Actions
struct ContentView: View { @State private var isInCart: Bool = false var body: some View { VStack(alignment: .leading) { ItemDescriptionView() addToCartButton } .accessibilityQuickAction(style: .prompt) { addToCartButton } } var addToCartButton: some View { Button(isInCart ? "Remove from cart" : "Add to cart") { isInCart.toggle() } } } struct ItemDescriptionView: View { var body: some View { ScrollView { VStack { HStack { Text("🎈") .font(.title2) Text("Balloons") .font(.title3) Spacer() } .padding(.bottom, 4) Text( """ This is perhaps our funniest product! It is made up of a rubber fabric and comes in various unique colors. """) .font(.caption) } } } }
-
20:20 - Tables on iPadOS
struct ContentView: View { @StateObject private var attendeeStore = AttendeeStore() var body: some View { NavigationStack { Table(attendeeStore.attendees) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
21:12 - Context Menu
struct ContentView: View { @StateObject private var attendeeStore = AttendeeStore() @State private var selection = Set<Attendee.ID>() var body: some View { NavigationStack { Table(attendeeStore.attendees, selection: $selection) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") #if os(macOS) .contextMenu(forSelectionType: Attendee.ID.self) { selection in if selection.isEmpty { Button("New Invitation") { addInvitation() } } else if selection.count == 1 { Button("Mark as VIP") { markVIPs(selection) } } else { Button("Mark as VIPs") { markVIPs(selection) } } } #endif .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } private func addInvitation() {} private func markVIPs(_ items: Set<String>) {} } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
22:12 - Customizable toolbars
struct ContentView: View { @StateObject private var attendeeStore = AttendeeStore() @State private var selection = Set<Attendee.ID>() var body: some View { NavigationStack { Table(attendeeStore.attendees, selection: $selection) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") #if os(macOS) .contextMenu(forSelectionType: Attendee.ID.self) { selection in if selection.isEmpty { Button("New Invitation") { addInvitation() } } else if selection.count == 1 { Button("Mark as VIP") { markVIPs(selection) } } else { Button("Mark as VIPs") { markVIPs(selection) } } } #endif .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } private func addInvitation() {} private func markVIPs(_ items: Set<String>) {} } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
23:17 - Search Tokens
struct ContentView: View { public struct AttendeeToken: Identifiable, Equatable, Hashable { enum Guts { case name case location case status } let guts: Guts var query: String = .init() var id: String { self.systemImage } static let allCases: [AttendeeToken] = [.name, .location, .status] mutating func displayName(_ query: String) -> String { self.query = query switch guts { case .name: return "Name contains: \(query)" case .location: return "City contains: \(query)" case .status: return "Status contains: \(query)" } } var systemImage: String { switch guts { case .name: return "person" case .location: return "location.square" case .status: return "person.crop.circle.badge" } } static let name: AttendeeToken = .init(guts: .name) static let location: AttendeeToken = .init(guts: .location) static let status: AttendeeToken = .init(guts: .status) } @StateObject private var attendeeStore = AttendeeStore() @State private var selection = Set<Attendee.ID>() @State private var tokens: [AttendeeToken] = .init() @State private var query: String = .init() var body: some View { NavigationStack { Table(attendeeStore.attendees, selection: $selection) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") #if os(macOS) .contextMenu(forSelectionType: Attendee.ID.self) { selection in if selection.isEmpty { Button("New Invitation") { addInvitation() } } else if selection.count == 1 { Button("Mark as VIP") { markVIPs(selection) } } else { Button("Mark as VIPs") { markVIPs(selection) } } } #endif .searchable(text: $query, tokens: $tokens) { token in Label(token.query, systemImage: token.systemImage) } suggestions: { suggestions } .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } @ViewBuilder private var suggestions: some View { ForEach(attendeeStore.attendees) { Text($0.name) .foregroundColor(.black) } if !query.isEmpty { ForEach(AttendeeToken.allCases) { token in var _token = token Label(_token.displayName(query), systemImage: _token.systemImage) .searchCompletion(_token) } } } private func addInvitation() {} private func markVIPs(_ items: Set<String>) {} } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
23:28 - Search scopes
struct ContentView: View { enum AttendanceScope { case inPerson case online } public struct AttendeeToken: Identifiable, Equatable, Hashable { enum Guts { case name case location case status } let guts: Guts var query: String = .init() var id: String { self.systemImage } static let allCases: [AttendeeToken] = [.name, .location, .status] mutating func displayName(_ query: String) -> String { self.query = query switch guts { case .name: return "Name contains: \(query)" case .location: return "City contains: \(query)" case .status: return "Status contains: \(query)" } } var systemImage: String { switch guts { case .name: return "person" case .location: return "location.square" case .status: return "person.crop.circle.badge" } } static let name: AttendeeToken = .init(guts: .name) static let location: AttendeeToken = .init(guts: .location) static let status: AttendeeToken = .init(guts: .status) } @StateObject private var attendeeStore = AttendeeStore() @State private var selection = Set<Attendee.ID>() @State private var tokens: [AttendeeToken] = .init() @State private var query: String = .init() @State private var scope: AttendanceScope = .inPerson var body: some View { NavigationStack { Table(attendeeStore.attendees, selection: $selection) { TableColumn("Name") { attendee in AttendeeRow(attendee) } TableColumn("City", value: \.city) TableColumn("Status") { attendee in StatusRow(attendee) } } .navigationTitle("Invitations") #if os(macOS) .contextMenu(forSelectionType: Attendee.ID.self) { selection in if selection.isEmpty { Button("New Invitation") { addInvitation() } } else if selection.count == 1 { Button("Mark as VIP") { markVIPs(selection) } } else { Button("Mark as VIPs") { markVIPs(selection) } } } #endif .searchable( text: $query, tokens: $tokens, scope: $scope ) { token in Label( token.query, systemImage: token.systemImage) } scopes: { Text("In Person").tag(AttendanceScope.inPerson) Text("Online").tag(AttendanceScope.online) } suggestions: { suggestions } .toolbar(id: "toolbar") { ToolbarItem(id: "new", placement: .secondaryAction) { Button(action: {}) { Label("New Invitation", systemImage: "envelope") } } ToolbarItem(id: "edit", placement: .secondaryAction) { Button(action: {}) { Label("Edit", systemImage: "pencil.circle") } } ToolbarItem(id: "share", placement: .secondaryAction) { Button(action: {}) { Label("Share", systemImage: "square.and.arrow.up") } } ToolbarItem(id: "tag", placement: .secondaryAction) { Button(action: {}) { Label("Tags", systemImage: "tag") } } ToolbarItem( id: "reminder", placement: .secondaryAction, showsByDefault: false ) { Button(action: {}) { Label("Set reminder", systemImage: "bell") } } } .toolbarRole(.editor) } } @ViewBuilder private var suggestions: some View { ForEach(attendeeStore.attendees) { Text($0.name) .foregroundColor(.black) } if !query.isEmpty { ForEach(AttendeeToken.allCases) { token in var _token = token Label(_token.displayName(query), systemImage: _token.systemImage) .searchCompletion(_token) } } } private func addInvitation() {} private func markVIPs(_ items: Set<String>) {} } class AttendeeStore: ObservableObject { @Published var attendees: [Attendee] = [/* Default attendees */] } struct Attendee: Identifiable, Hashable { enum Status: String { case accepted, declined, maybe func displayText() -> Text { switch self { case .accepted: return Text( "Accepted \(Image(systemName: "person.crop.circle.badge.checkmark"))") case .maybe: return Text( "Maybe \(Image(systemName: "person.crop.circle.badge.questionmark"))") case .declined: return Text( "Declined \(Image(systemName: "person.crop.circle.badge.minus"))") } } } let id = UUID() let memojiName: String let name: String let city: String let status: Status init(memojiName: String, name: String, cities: String, status: Status) { self.memojiName = memojiName self.name = name self.city = cities self.status = status } } struct AttendeeRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { HStack { Image(attendee.memojiName) .resizable() .aspectRatio(contentMode: .fill) #if os(macOS) .frame(width: 20, height: 20) .overlay { Circle() .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #else .frame(width: 32, height: 32) .overlay { RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) } #endif Text(attendee.name) } } } struct StatusRow: View { let attendee: Attendee init(_ attendee: Attendee) { self.attendee = attendee } var body: some View { attendee.status.displayText() .symbolVariant(.fill) .symbolRenderingMode(.multicolor) } }
-
24:45 - PhotosPicker
import PhotosUI import CoreTransferable struct ContentView: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { NavigationStack { Gallery() .navigationTitle("Birthday Filter") .toolbar { PhotosPicker( selection: $viewModel.imageSelection, matching: .images ) { Label("Pick a photo", systemImage: "plus.app") } Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } } } } } struct Gallery: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { VStack { switch viewModel.imageState { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .draggable(image) case .loading: ProgressView() case .empty: Text("No Photo \(Image(systemName: "photo"))") .font(.title2) .fontWeight(.semibold) Text("Drag and drop a photo or press\n \(Image(systemName: "plus.app")) to choose a photo manually.") .foregroundColor(.secondary) .multilineTextAlignment(.center) case .failure: Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.white) } } .padding() } } @MainActor class FilterModel: ObservableObject { static let shared = FilterModel() enum ImageState { case empty, loading(Progress), success(Image), failure(Error) } @Published private(set) var processedImage: Image? @Published var imageState: ImageState = .empty @Published var imageSelection: PhotosPickerItem? = nil { didSet { if let imageSelection = imageSelection { let progress = loadTransferable(from: imageSelection) imageState = .loading(progress) } else { imageState = .empty } } } func applyFilter() { /* Apply your filter */ } private func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress { return imageSelection.loadTransferable(type: Image.self) { result in DispatchQueue.main.async { guard imageSelection == self.imageSelection else { return } switch result { case .success(let image?): self.imageState = .success(image) case .success(nil): self.imageState = .empty case .failure(let error): self.imageState = .failure(error) } } } } }
-
25:51 - ShareLink
import PhotosUI import CoreTransferable struct ContentView: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { NavigationStack { Gallery() .navigationTitle("Birthday Filter") .toolbar { PhotosPicker( selection: $viewModel.imageSelection, matching: .images ) { Label("Pick a photo", systemImage: "plus.app") } Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } } } } } struct Gallery: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { VStack { switch viewModel.imageState { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .draggable(image) case .loading: ProgressView() case .empty: Text("No Photo \(Image(systemName: "photo"))") .font(.title2) .fontWeight(.semibold) Text("Drag and drop a photo or press\n \(Image(systemName: "plus.app")) to choose a photo manually.") .foregroundColor(.secondary) .multilineTextAlignment(.center) case .failure: Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.white) } } .padding() } } @MainActor class FilterModel: ObservableObject { static let shared = FilterModel() enum ImageState { case empty, loading(Progress), success(Image), failure(Error) } @Published private(set) var processedImage: Image? @Published var imageState: ImageState = .empty @Published var imageSelection: PhotosPickerItem? = nil { didSet { if let imageSelection = imageSelection { let progress = loadTransferable(from: imageSelection) imageState = .loading(progress) } else { imageState = .empty } } } func applyFilter() { /* Apply your filter */} private func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress { return imageSelection.loadTransferable(type: Image.self) { result in DispatchQueue.main.async { guard imageSelection == self.imageSelection else { return } switch result { case .success(let image?): self.imageState = .success(image) case .success(nil): self.imageState = .empty case .failure(let error): self.imageState = .failure(error) } } } } }
-
26:17 - Context Menu
import PhotosUI import CoreTransferable struct ContentView: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { NavigationStack { Gallery() .navigationTitle("Birthday Filter") .toolbar { PhotosPicker( selection: $viewModel.imageSelection, matching: .images ) { Label("Pick a photo", systemImage: "plus.app") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } } .contextMenu { Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } Button(role: .destructive) { viewModel.deleteCurrentPhoto() } label: { Label("Delete", systemImage: "trash") } } } } } struct Gallery: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { VStack { switch viewModel.imageState { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .draggable(image) case .loading: ProgressView() case .empty: Text("No Photo \(Image(systemName: "photo"))") .font(.title2) .fontWeight(.semibold) Text("Drag and drop a photo or press\n \(Image(systemName: "plus.app")) to choose a photo manually.") .foregroundColor(.secondary) .multilineTextAlignment(.center) case .failure: Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.white) } } .padding() } } @MainActor class FilterModel: ObservableObject { static let shared = FilterModel() enum ImageState { case empty, loading(Progress), success(Image), failure(Error) } @Published private(set) var processedImage: Image? @Published var imageState: ImageState = .empty @Published var imageSelection: PhotosPickerItem? = nil { didSet { if let imageSelection = imageSelection { let progress = loadTransferable(from: imageSelection) imageState = .loading(progress) } else { imageState = .empty } } } func applyFilter() { /* Apply your filter */} func deleteCurrentPhoto() {} private func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress { return imageSelection.loadTransferable(type: Image.self) { result in DispatchQueue.main.async { guard imageSelection == self.imageSelection else { return } switch result { case .success(let image?): self.imageState = .success(image) case .success(nil): self.imageState = .empty case .failure(let error): self.imageState = .failure(error) } } } } }
-
26:50 - Drop destination
import PhotosUI import CoreTransferable struct ContentView: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { NavigationStack { Gallery() .navigationTitle("Birthday Filter") .toolbar { PhotosPicker( selection: $viewModel.imageSelection, matching: .images ) { Label("Pick a photo", systemImage: "plus.app") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } } .contextMenu { Button { viewModel.applyFilter() } label: { Label("Apply Filter", systemImage: "camera.filters") } if let item = viewModel.processedImage { ShareLink( item: item, preview: SharePreview("Birthday Effects")) } Button(role: .destructive) { viewModel.deleteCurrentPhoto() } label: { Label("Delete", systemImage: "trash") } } .dropDestination(payloadType: Image.self) { receivedImages, location in guard let image = receivedImages.first else { return false } viewModel.imageState = .success(image) return true } } } } struct Gallery: View { @ObservedObject var viewModel: FilterModel = .shared var body: some View { VStack { switch viewModel.imageState { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .draggable(image) case .loading: ProgressView() case .empty: Text("No Photo \(Image(systemName: "photo"))") .font(.title2) .fontWeight(.semibold) Text("Drag and drop a photo or press\n \(Image(systemName: "plus.app")) to choose a photo manually.") .foregroundColor(.secondary) .multilineTextAlignment(.center) case .failure: Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundColor(.white) } } .padding() } } @MainActor class FilterModel: ObservableObject { static let shared = FilterModel() enum ImageState { case empty, loading(Progress), success(Image), failure(Error) } @Published private(set) var processedImage: Image? @Published var imageState: ImageState = .empty @Published var imageSelection: PhotosPickerItem? = nil { didSet { if let imageSelection = imageSelection { let progress = loadTransferable(from: imageSelection) imageState = .loading(progress) } else { imageState = .empty } } } func applyFilter() { /* Apply your filter */} func deleteCurrentPhoto() {} private func loadTransferable(from imageSelection: PhotosPickerItem) -> Progress { return imageSelection.loadTransferable(type: Image.self) { result in DispatchQueue.main.async { guard imageSelection == self.imageSelection else { return } switch result { case .success(let image?): self.imageState = .success(image) case .success(nil): self.imageState = .empty case .failure(let error): self.imageState = .failure(error) } } } } }
-
28:15 - Shape Styles: CalendarIcon
struct CalendarIcon: View { var body: some View { VStack { Image(systemName: "calendar") .font(.system(size: 80, weight: .medium)) Text("June 6") } .background(in: Circle().inset(by: -20)) .backgroundStyle( .blue .gradient ) .foregroundStyle(.white.shadow(.drop(radius: 1, y: 1.5))) .padding(20) } }
-
28:49 - Shape Styles: Icon Grid
struct Icon: View { let systemSymbolName: String let color: Color let shadow: ShadowStyle var foregroundColor: Color = .white var body: some View { VStack { Image(systemName: systemSymbolName) .resizable() .aspectRatio(1.0, contentMode: .fit) .padding(2) } .background(in: Circle().inset(by: -20)) .backgroundStyle( color .gradient ) .foregroundStyle(foregroundColor.shadow(shadow)) .padding(20) } } private let dropStyle = ShadowStyle.drop(radius: 1, y: 1.5) private let innerStyle = ShadowStyle.inner(radius: 1.5) let icons: [Icon] = [ Icon(systemSymbolName: "person", color: .red, shadow: dropStyle), Icon(systemSymbolName: "basketball", color: .orange, shadow: dropStyle), Icon(systemSymbolName: "globe.central.south.asia", color: .yellow, shadow: innerStyle), Icon(systemSymbolName: "carrot", color: .green, shadow: innerStyle, foregroundColor: .orange), Icon(systemSymbolName: "sailboat", color: .mint, shadow: innerStyle), Icon(systemSymbolName: "figure.open.water.swim", color: .teal, shadow: dropStyle), Icon(systemSymbolName: "ladybug.fill", color: .cyan, shadow: innerStyle), Icon(systemSymbolName: "calendar", color: .blue, shadow: dropStyle), Icon(systemSymbolName: "moon.stars", color: .indigo, shadow: dropStyle), Icon(systemSymbolName: "brain.head.profile", color: .purple, shadow: innerStyle), Icon(systemSymbolName: "birthday.cake", color: .pink, shadow: dropStyle), Icon(systemSymbolName: "house.circle.fill", color: .white, shadow: dropStyle), Icon(systemSymbolName: "lizard", color: .brown, shadow: dropStyle), Icon(systemSymbolName: "flag.checkered", color: .black, shadow: dropStyle), Icon(systemSymbolName: "character.book.closed", color: .gray, shadow: dropStyle), ] struct IconGrid: View { var body: some View { Grid(horizontalSpacing: 16, verticalSpacing: 16) { ForEach(0..<3) { i in GridRow { ForEach(0..<5) { j in icons[i * 5 + j] } } } } .background(.black.opacity(0.8)) } }
-
29:07 - Graphics: Dancing symbol grid
// MARK: - Dancing Symbol Grid struct SymbolSquare: View { let color: Color let imageName: String var image: some View { Image(systemName: imageName) .resizable() .aspectRatio(contentMode: .fit) .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } var body: some View { image .background { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill( .ellipticalGradient( color .gradient ) ) } } } /// If `true`, the party will commence. private let startTheParty = false private let partySymbols = ["party.popper", "balloon", "balloon.2", "birthday.cake"] struct DancingSymbolSquare: View { let color: Color let imageName: String /// Allows staggered dancing — doesn't look quite as nice. let seed: Int private let timer = Timer.publish(every: 0.234378662, on: .main, in: .default) @State private var cancellable: Cancellable? = nil @State private var heavy = false @State var fontSize = 20 as CGFloat var body: some View { SymbolSquare(color: color, imageName: imageName) .font(.body.weight(heavy ? .black : .thin)) .onReceive(timer) { date in if heavy { withAnimation(.easeOut(duration: 0.468757324 - 0.1)) { heavy.toggle() } } else { withAnimation(.easeIn(duration: 0.1)) { heavy.toggle() } } } .onAppear { if startTheParty { DispatchQueue.main.asyncAfter(deadline: .now() + Double(seed) * 0.25) { cancellable = timer.connect() } } } .drawingGroup(opaque: true) } } struct SymbolGrid: View { var body: some View { Grid { GridRow { DancingSymbolSquare(color: .yellow, imageName:partySymbols[0], seed: 0) DancingSymbolSquare(color: .green, imageName: partySymbols[1], seed: 0) } GridRow { DancingSymbolSquare(color: .indigo, imageName: partySymbols[2], seed: 0) DancingSymbolSquare(color: .purple, imageName: partySymbols[3], seed: 0) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } }
-
30:15 - Graphics: Text transitions
struct TextTransitionsView: View { @State private var expandMessage = true private let mintWithShadow: AnyShapeStyle = AnyShapeStyle(Color.mint.shadow(.drop(radius: 2))) private let primaryWithoutShadow: AnyShapeStyle = AnyShapeStyle(Color.primary.shadow(.drop(radius: 0))) var body: some View { Text("Happy Birthday SwiftUI!") .font(expandMessage ? .largeTitle.weight(.heavy) : .body) .foregroundStyle(expandMessage ? mintWithShadow : primaryWithoutShadow) .onTapGesture { withAnimation { expandMessage.toggle() }} .frame(maxWidth: expandMessage ? 160 : 250) .drawingGroup() .padding(20) .background(.pink.opacity(0.3), in: RoundedRectangle(cornerRadius: 6)) } }
-
31:16 - Layout: Grid
struct VIPDetailView: View { var body: some View { Grid { GridRow { NameHeadline() .gridCellColumns(2) } GridRow { CalendarIcon() SymbolGrid() } } .frame(width: 300, height: 300) } } struct NameHeadline: View { var body: some View { HStack { Color.green.background(in: RoundedRectangle(cornerRadius: 8)) .frame(maxWidth: .infinity, maxHeight: .infinity) VStack(alignment: .leading) { Text("Franck Ndame Mpouli") .font(.title2) .foregroundStyle(.shadow(.drop(radius: 2, y: 3))) Text("Party Planning Committee").bold() } } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) .background( .white.gradient, in: RoundedRectangle(cornerRadius: 12, style: .continuous) ) } } struct CalendarIcon: View { var body: some View { VStack { Image(systemName: "calendar") .font(.system(size: 80, weight: .medium)) Text("June 6") } .background(in: Circle().inset(by: -20)) .backgroundStyle( .blue .gradient ) .foregroundStyle(.white.shadow(dropStyle)) .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity) } }
-
32:04 - Layout: Seating Chart Layout
// MARK: Custom Table Layout private let tableSize = CGSize(width: 130, height: 90) private let guestSize = CGSize(width: 40, height: 40) /// Which of 6 tables this view represents private struct TableViewLayoutKey: LayoutValueKey { static let defaultValue: Int? = nil } extension View { fileprivate func tableViewLayoutKey(_ value: Int) -> some View { return layoutValue(key: TableViewLayoutKey.self, value: value) } } /// Which of 36 guests this view represents private struct GuestViewLayoutKey: LayoutValueKey { static let defaultValue: Int? = 0 } extension View { /// Guests 1 - 36 fileprivate func guestViewLayoutKey(_ value: Int) -> some View { return layoutValue(key: GuestViewLayoutKey.self, value: value) } } let initials = [ "Ju", "As", "Ma", "As", "Ly", "Ga", "Ni", "Ar", "Ca", "Do", "Je", "Ca", "Em", "Ma", "Ze", "Jo", "Da", "Sh", "Sa", "Pl", "Pa", "Sc", "Ma", "Je", "Li", "Ma", "Ta", "Je", "Cu", "Lu", "Ra", "Na", "Sa", "Pa", "Le", "Pi", ] struct SeatingChartView: View { /// If true, the guests will be positioned in "pods" of tables. No table will touch another table. Otherwise /// the guests will side in two longs rows. @State private var usePods = true var body: some View { ZStack(alignment: .bottomTrailing) { GeometryReader { proxy in SeatingLayout(usePods: usePods).callAsFunction { TableView(tableNumber: 1) TableView(tableNumber: 2) TableView(tableNumber: 3) TableView(tableNumber: 4) TableView(tableNumber: 5) TableView(tableNumber: 6) ForEach(1..<37) { i in SeatedGuestOption2(guestNumber: i - 1) } } .animation(.default, value: proxy.size) } .background(.black.opacity(0.13)) Picker("Arrangement", selection: $usePods.animation()) { Text("Pods").tag(true) Text("Rows ").tag(false) } .fixedSize() .pickerStyle(.segmented) .padding() } } } /// heh. struct TableView: View { let tableNumber: Int var body: some View { ZStack(alignment: .bottomTrailing) { HStack { Image(systemName: "table.furniture") .background(.quaternary.shadow(.inner(radius: 1, y: 1.5)), in: Circle().inset(by: -8)) .padding(5) Text("Table \(tableNumber)") } .foregroundStyle(.secondary) .padding(8) .frame(width: tableSize.width, height: tableSize.height) #if os(macOS) || os(iOS) .background(.regularMaterial.shadow(.drop(radius: 1, y: 1.5)), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) #endif } .tableViewLayoutKey(tableNumber) } } private let colors: [Color] = [ .red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .black, .white, .brown, .red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .black, .white, .brown, .red, .orange, .yellow, .green, .mint, .teal, .cyan ] struct SeatedGuest: View { let guestNumber: Int var body: some View { Image(systemName: "person") .resizable() .aspectRatio(contentMode: .fit) .padding(9) .background(in: Circle()) .backgroundStyle( colors[guestNumber].gradient ) .foregroundStyle(guestNumber == 13 ? .black : .white) .frame(width: 40, height: 40) .guestViewLayoutKey(guestNumber + 1) } } struct SeatedGuestOption2: View { let guestNumber: Int var body: some View { Circle() .stroke(colors[guestNumber], style: StrokeStyle(lineWidth: 3)) .background(.white.gradient, in: Circle()) .frame(width: guestSize.width, height: guestSize.height) .guestViewLayoutKey(guestNumber + 1) .overlay { Text(initials[guestNumber]) .foregroundColor(.secondary) .font(.callout) } } } struct SeatingChartView_Previews: PreviewProvider { static var previews: some View { SeatingChartView() .frame(width: 600, height: 600) } } struct SeatingLayout: Layout { /// If true, the guests will be positioned in "pods" of tables. No table will touch another table. Otherwise /// the guests will side in two longs rows. let usePods: Bool struct Cache { /// The width proposed to the view. We assume a certain height, otherwise, overlapping views var width: CGFloat? } func sizeThatFits( proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout Cache ) -> CGSize { cache.width = proposal.width return proposal.replacingUnspecifiedDimensions() } func makeCache(subviews: Subviews) -> Cache { Cache() } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { guard let width = cache.width else { return } /// Helper function: Place 6 guests around all edges of a table. func seat(_ guests: [LayoutSubview], around table: CGRect) { guests[0].place( at: .init( x: table.origin.x + 3 - guestSize.width, y: table.origin.y + (table.height / 2.0) - (guestSize.height / 2.0)), proposal: .infinity) guests[1].place( at: .init( x: table.origin.x + (table.width / 4.0) - guestSize.width / 2.0, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[2].place( at: .init( x: table.origin.x + table.width * 0.75 - guestSize.width / 2.0, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[3].place( at: .init( x: table.maxX - 5, y: table.origin.y + (table.height / 2.0) - (guestSize.height / 2.0)), proposal: .infinity) guests[4].place( at: .init( x: table.origin.x + table.width * 0.75 - guestSize.width / 2.0, y: table.maxY - 5), proposal: .infinity) guests[5].place( at: .init( x: table.origin.x + (table.width / 4.0) - guestSize.width / 2.0, y: table.maxY - 5), proposal: .infinity) } /// Helper function: Place 6 guests, dining hall style (not along the shorter sides of a table) func seat(_ guests: [LayoutSubview], along table: CGRect) { guests[0].place( at: .init( x: table.minX + tableSize.width / 3 - guestSize.width - 4, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[1].place( at: .init( x: table.minX + tableSize.width * 2/3 - guestSize.width - 4, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[2].place( at: .init( x: table.minX + tableSize.width - guestSize.width - 4, y: table.origin.y + 5 - guestSize.height), proposal: .infinity) guests[3].place( at: .init( x: table.minX + tableSize.width / 3 - guestSize.width - 4, y: table.maxY - 5), proposal: .infinity) guests[4].place( at: .init( x: table.minX + tableSize.width * 2/3 - guestSize.width - 4, y: table.maxY - 5), proposal: .infinity) guests[5].place( at: .init( x: table.minX + tableSize.width - guestSize.width - 4, y: table.maxY - 5), proposal: .infinity) } // Get tables let table1 = subviews.first(where: { $0[TableViewLayoutKey.self] == 1 })! let table2 = subviews.first(where: { $0[TableViewLayoutKey.self] == 2 })! let table3 = subviews.first(where: { $0[TableViewLayoutKey.self] == 3 })! let table4 = subviews.first(where: { $0[TableViewLayoutKey.self] == 4 })! let table5 = subviews.first(where: { $0[TableViewLayoutKey.self] == 5 })! let table6 = subviews.first(where: { $0[TableViewLayoutKey.self] == 6 })! // Get guests let table1Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 1 && guestNumber <= 6 } let table2Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 7 && guestNumber <= 12 } let table3Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 13 && guestNumber <= 18 } let table4Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 19 && guestNumber <= 24 } let table5Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 25 && guestNumber <= 30 } let table6Guests = subviews .filter { guard let guestNumber = $0[GuestViewLayoutKey.self] else { return false } return guestNumber >= 31 && guestNumber <= 36 } if usePods { let table1Origin = CGPoint(x: 60, y: 120) let table2Origin = CGPoint(x: 200, y: 280) let table3Origin = CGPoint(x: 50, y: 450) let table4Origin = CGPoint(x: 300, y: 120) let table5Origin = CGPoint(x: 440, y: 280) let table6Origin = CGPoint(x: 290, y: 450) table1.place(at: table1Origin, proposal: .infinity) table2.place(at: table2Origin, proposal: .infinity) table3.place(at: table3Origin, proposal: .infinity) table4.place(at: table4Origin, proposal: .infinity) table5.place(at: table5Origin, proposal: .infinity) table6.place(at: table6Origin, proposal: .infinity) seat(table1Guests, around: CGRect(origin: table1Origin, size: tableSize)) seat(table2Guests, around: CGRect(origin: table2Origin , size: tableSize)) seat(table3Guests, around: CGRect(origin: table3Origin, size: tableSize)) seat(table4Guests, around: CGRect(origin: table4Origin, size: tableSize)) seat(table5Guests, around: CGRect(origin: table5Origin , size: tableSize)) seat(table6Guests, around: CGRect(origin: table6Origin, size: tableSize)) } else { let table1Origin = CGPoint(x: width / 2.0 - 6 - tableSize.width * 1.5, y: 130) let table2Origin = CGPoint(x: table1Origin.x + tableSize.width + 6, y: 130) let table3Origin = CGPoint(x: table2Origin.x + tableSize.width + 6, y: 130) let table4Origin = CGPoint(x: width / 2.0 - 6 - tableSize.width * 1.5, y: 360) let table5Origin = CGPoint(x: table1Origin.x + tableSize.width + 6, y: 360) let table6Origin = CGPoint(x: table2Origin.x + tableSize.width + 6, y: 360) table1.place(at: table1Origin, proposal: .infinity) table2.place(at: table2Origin, proposal: .infinity) table3.place(at: table3Origin, proposal: .infinity) table4.place(at: table4Origin, proposal: .infinity) table5.place(at: table5Origin, proposal: .infinity) table6.place(at: table6Origin, proposal: .infinity) seat(table1Guests, along: CGRect(origin: table1Origin, size: tableSize)) seat(table2Guests, along: CGRect(origin: table2Origin , size: tableSize)) seat(table3Guests, along: CGRect(origin: table3Origin, size: tableSize)) seat(table4Guests, along: CGRect(origin: table4Origin, size: tableSize)) seat(table5Guests, along: CGRect(origin: table5Origin , size: tableSize)) seat(table6Guests, along: CGRect(origin: table6Origin, size: tableSize)) } } }
-
32:50 - AnyLayout invitation
import SwiftUI import GameplayKit import Combine @main struct InvitationApp: App { var body: some Scene { WindowGroup { PolygonDesignerView() .environmentObject(PolygonModel()) #if os(iOS) .statusBar(hidden: true) #endif .edgesIgnoringSafeArea(.all) } } } // MARK: Views /// A view that arranges polygons in a grid, or a custom, scattered layout. private struct DynamicPolygonView: View { @EnvironmentObject var model: PolygonModel @Binding var cycleLayouts: Bool private var sideLength: Int { Int(CGFloat(model.polygonGeometries.count).squareRoot()) } /// Timer whose ticking dictates how often to regenerate and animate-to a new scattered layout. /// - Note: The layout will only transition if `cycleLayouts` is `true`. private let layoutChangingTimer = Timer .publish(every: 1.2, on: .current, in: .default).autoconnect() /// Animation used to transition layouts private let animation = Animation.easeInOut(duration: 1.3) /// Timer that ticks at 128 beats per minute, matching the beat of the song in the WWDC session. let musicBeatTimer = Timer .publish(every: 0.234378662, tolerance: 0, on: .main, in: .default) @State private var musicBeatTimerCancellable: (any Cancellable)? = nil /// Whether or not the font should be rendered heavy. @State private var heavy: Bool = false @State private var scatteredLayout = newScatteredLayout( Date(timeIntervalSince1970: 0) ) /// By providing a seed value, the `ScatteredLayout` struct will know when to bust its cache and /// generate new layout data. private static func newScatteredLayout(_ seed: Date) -> ScatteredLayout { ScatteredLayout(count: PolygonModel.total, seed: seed.timeIntervalSinceReferenceDate, textAvoidanceRect: CGRect( x: 152, y: 245, width: 220, height: 40) ) } var body: some View { let layout = model.usesGridLayout ? AnyLayout(Grid(alignment: .center, horizontalSpacing: 0, verticalSpacing: 0)) : AnyLayout(scatteredLayout) ZStack(alignment: .center) { Label(title: { Text("You're Invited") }, icon: { Image(systemName: "party.popper.fill")}) .font(.system(size:100).weight(heavy ? .black : .thin)) .onTapGesture { musicBeatTimerCancellable = musicBeatTimer.connect() } .zIndex(-1) layout { ForEach((0..<sideLength), id: \.self) { row in GridRow { // GridRow is a no-op in non-Grid layouts ForEach((0..<sideLength), id: \.self) { column in let polygon = model .polygonGeometries[sideLength * row + column] PolygonView(polygonGeometry: polygon) .polygonViewLayoutKey(polygon) } } } } } .drawingGroup() .frame(maxWidth: .infinity, maxHeight: .infinity) .onReceive(musicBeatTimer) { date in if heavy { // Transitioning to a thin font happens slowly withAnimation(.easeOut(duration: 0.468757324 - 0.1)) { heavy.toggle() } } else { // Transitioning to thick happens quickly, to give the // appearance of a "strong" downbeat withAnimation(.easeIn(duration: 0.1)) { heavy.toggle() } } } .onReceive(layoutChangingTimer) { date in guard cycleLayouts else { return } withAnimation(animation) { scatteredLayout = DynamicPolygonView.newScatteredLayout(date) } } } } private struct PolygonDesignerView: View { @EnvironmentObject var model: PolygonModel @State var cycleLayouts = false @State var hideDesignerView = true var body: some View { ZStack(alignment: .bottom) { DynamicPolygonView(cycleLayouts: $cycleLayouts) .onTapGesture(count: 2) { withAnimation { hideDesignerView.toggle() } } ControlView(cycleLayouts: $cycleLayouts) .padding() .background(.thickMaterial) .offset(CGSize(width: 0, height: hideDesignerView ? 300 : 0)) } } } /// Tunes the parameters of a `PolygonModel` private struct ControlView: View { /// The instance `self` tunes the parameters of. @EnvironmentObject var model: PolygonModel /// Can be used by a parent view to cycle through instances of layouts. @Binding var cycleLayouts: Bool var body: some View { VStack { Button("Reset", action: model.reset) let layout = HStack() layout { Toggle("Tiled", isOn: Binding(get: { model.tiled }, set: { tile in // After toggled, wait 5 seconds, then transition back to a // scattered layout DispatchQueue.main.asyncAfter(deadline: .now() + 5) { withAnimation(.linear(duration: 1.4)) { model.usesGridLayout = false model.drawAsRandomPolygons = true } } withAnimation(.linear(duration: 1.8)) { model.usesGridLayout = tile model.drawAsRandomPolygons = !tile } })) Toggle("Cycle Layouts", isOn: $cycleLayouts) } } .padding(2) } } // MARK: PolygonView /// Wraps a ``Polygon`` shape applying a fill. private struct PolygonView: View { var polygonGeometry: PolygonGeometry var body: some View { Polygon(polygonGeometry: polygonGeometry) .fill(polygonGeometry.color) } } /// A Polygon shape that supports any number of sides as defined by `polygonGeometry` private struct Polygon: Shape { var polygonGeometry: PolygonGeometry typealias AnimatableData = AnimatableVector var animatableData: AnimatableVector { get { polygonGeometry.vectorPath } set { polygonGeometry.points = newValue.points } } func path(in rect: CGRect) -> Path { // Scale up the shape's path to fill as much space as it is given let path = polygonGeometry.path let boundingRect = path.boundingRect let xScale = rect.width / boundingRect.width let yScale = rect.height / boundingRect.height let translate = CGAffineTransform( translationX: -boundingRect.origin.x * xScale, y: -boundingRect.origin.y * yScale ) let scale = CGAffineTransform(scaleX: xScale, y: yScale) return path.applying(scale.concatenating(translate)) } func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { if proposal == .infinity { // If proposed infinite space, use the preferred, absolute size. return CGSize(width: polygonGeometry.sideLength, height: polygonGeometry.sideLength) } else { // If we don't have infinite space, assume we've been given all the // space the parent view can afford, and take all of it. return proposal.replacingUnspecifiedDimensions() } } } // MARK: ScatteredLayout private struct PolygonViewLayoutKey: LayoutValueKey { static let defaultValue: PolygonGeometry? = nil } extension View { fileprivate func polygonViewLayoutKey(_ value: PolygonGeometry) -> some View { return layoutValue(key: PolygonViewLayoutKey.self, value: value) } } /// ScatteredLayout assumes a certain standard size and lays out its views /// (tagged with `PolygonViewLayoutKey` data) such that they don't collide /// within that size. As the size grows, the shapes stay the same size, /// but get farther or closer. private struct ScatteredLayout: Layout { /// Cache data for a `ScatteredLayout`. struct Cache { /// Maps a `PolygonGeometry.id` to its position in a `standardSize` /// coordinate space. var rects: [UUID: CGRect] /// Used as a cache buster. var seed: TimeInterval? } /// The smallest size a view using this layout can be. private let minimumBaseSize: CGSize /// The base coordinate system this view assumes when laying out. private let standardSize: CGSize = CGSize(width: 500, height: 500) /// Clients can pass a value here and polygons won't be placed in that rect. var textAvoidanceRect: CGRect = .zero /// If different, we've been requested to bust the cache, and create a new /// one. /// - Note the cache can persist across different instances of a /// `ScatteredLayout` private let seed: TimeInterval func sizeThatFits( proposal: ProposedViewSize, subviews: LayoutSubviews, cache: inout Cache ) -> CGSize { let proposedSize = proposal .replacingUnspecifiedDimensions(by: minimumBaseSize) return CGSize( width: proposedSize.width .clamped( to: minimumBaseSize.width..<CGFloat.greatestFiniteMagnitude ), height: proposedSize.height .clamped( to: minimumBaseSize.height..<CGFloat.greatestFiniteMagnitude ) ) } init(count: Int, seed: TimeInterval, textAvoidanceRect: CGRect = .zero) { self.seed = seed minimumBaseSize = CGSize(width: CGFloat(count), height: CGFloat(count)) self.textAvoidanceRect = textAvoidanceRect } func makeCache(subviews: Subviews) -> Cache { var cache: Cache = Cache(rects: [:], seed: self.seed) var placedPolygons: [CGRect] = [] for subview in subviews { guard let polygon = subview[PolygonViewLayoutKey.self] else { // This is the title text view, skip it. continue } var subviewsPreferredSize = subview.sizeThatFits(.infinity) var counter = 20 while counter > 0 { counter -= 1 let randomX = CGFloat.random(in: 0..<standardSize.width) let randomY: CGFloat if randomX > textAvoidanceRect.minX && randomX < textAvoidanceRect.maxX { // Pick from either above or below the avoidance rect if Bool.random() { randomY = CGFloat.random( in: 0..<textAvoidanceRect.minY ) } else { randomY = CGFloat.random( in: textAvoidanceRect.maxY..<standardSize.height ) } } else { randomY = CGFloat.random(in: 0..<standardSize.height) } let origin = CGPoint(x: randomX, y: randomY) let rect = CGRect(origin: origin, size: subviewsPreferredSize) if placedPolygons.allSatisfy({ placed in !placed.intersects(rect) }) && !rect.intersects(textAvoidanceRect) { // The shape found a non-overlapping place to be. Lock in // it's position placedPolygons.append(rect) cache.rects[polygon.id] = CGRect(origin: origin, size: subviewsPreferredSize) break } else { if (counter == 0) { if rect.intersects(textAvoidanceRect) { subviewsPreferredSize = .zero } placedPolygons.append(rect) cache.rects[polygon.id] = CGRect(origin: origin, size: subviewsPreferredSize) } } } } return cache } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { // We have the frame value cached (via makeCache()) // for every view to be placed in a `standardSize` coordinate system. // Now we need to map that `standardSize` to the size was proposed. let proposedSize = proposal .replacingUnspecifiedDimensions(by: minimumBaseSize) let xProposedToBaseRatio = proposedSize.width / standardSize.width let yProposedToBaseRatio = proposedSize.height / standardSize.height for subview in subviews { guard let uuid = subview[PolygonViewLayoutKey.self]?.id, let rect = cache.rects[uuid] else { let desiredSize = subview.sizeThatFits(.zero) let centered = desiredSize.centered(in: bounds) subview.place( at: centered.origin, proposal: ProposedViewSize( width: desiredSize.width, height: desiredSize.height ) ) continue } let mappedPoint = CGPoint(x: rect.origin.x * xProposedToBaseRatio, y: rect.origin.y * yProposedToBaseRatio) subview.place(at: mappedPoint, proposal: ProposedViewSize(width: rect.size.width, height:rect.size.height) ) } } func updateCache(_ cache: inout Cache, subviews: Subviews) { // Bust the cache if we've been given a new seed value // or if our subviews have been swapped out from underneath us. if self.seed != cache.seed || !cache.rects.contains(where: { (key: UUID, value: CGRect) in subviews.first?[PolygonViewLayoutKey.self]?.id == key }) { cache = makeCache(subviews: subviews) return } } } /// This struct facilitates animation of point-based `Path`s so long as said /// source and destination `Path` have an equal number of vertices. private struct AnimatableVector: VectorArithmetic { static var zero: AnimatableVector = AnimatableVector(points: []) private(set) var points: [CGPoint] var magnitudeSquared: Double { let squared = points.map { point in CGPoint(x: point.x * point.x, y: point.y * point.y) } let sumOfSquares = squared.map { point in // dot product? sqrt(point.x + point.y) } let sum = sumOfSquares.reduce(0, +) return Double(sum) } /// Facilitates a valid `.zero` value, no matter the dimension of the vector subscript(safe index: Int) -> CGPoint { return (self.points.count <= index) ? .zero : points[index] } static func - (lhs: AnimatableVector, rhs: AnimatableVector) -> AnimatableVector { let negated = rhs.points.map { CGPoint(x: -$0.x, y: -$0.y) } return lhs + AnimatableVector(points: negated) } static func + (lhs: AnimatableVector, rhs: AnimatableVector) -> AnimatableVector { var output: [CGPoint] = [] for i in 0..<lhs.points.count { output.append(CGPoint(x: lhs[safe: i].x + rhs[safe: i].x, y:lhs[safe: i].y + rhs[safe: i].y )) } return AnimatableVector(points: output) } mutating func scale(by rhs: Double) { points = points.map { CGPoint(x: $0.x * CGFloat(rhs), y: $0.y * CGFloat(rhs)) } } } // MARK: Random Polygon Generation & Geometry private let mean: Float = 10 private let deviation: Float = 3 private let gaussian = GKGaussianDistribution( randomSource: GKARC4RandomSource(), mean: mean, deviation: deviation) /// Factory type for creating points describing a random Polygon private struct PolygonGeometry: Identifiable, Equatable, Hashable { /// The horizontal and vertical side lengths of the polygon's bounding box. let sideLength: CGFloat /// A constant count of the total points that comprise this /// `PolygonGeometry`'s path. Clients can set `points` to a new value, but /// the new value should have the same `count` for smooth `Path` animations let numberOfVertices: Int /// Supports animation of point-based `Path`s by providing an array of /// points that can be interpolated. var vectorPath: AnimatableVector { AnimatableVector(points: points) } /// If `false`, this instance will present itself as a rectangular shape /// (not necessarily with 4 vertices) that fills available space. private(set) var drawsAsPolygon: Bool = true /// Points describing the `Path` used to render `self`. var points: [CGPoint] { willSet { assert(points.count == polygonPathPoints.count) } } /// Delineate the path of the random polygon. private let polygonPathPoints: [CGPoint] let color: Color = [ Color(red: 0.73, green: 0.20, blue: 0.20), Color(red: 0.95, green: 0.66, blue: 0.24), Color(red: 0.14, green: 0.29, blue: 0.49), Color(red: 0.46, green: 0.76, blue: 0.67), Color(red: 0.30, green: 0.33, blue: 0.22), Color(red: 0.49, green: 0.55, blue: 0.64), Color(red: 0.92, green: 0.53, blue: 0.30), Color(red: 0.20, green: 0.45, blue: 0.55), Color(red: 0.41, green: 0.45, blue: 0.45), Color(red: 0.87, green: 0.67, blue: 0.61) ].randomElement()! private var spikiness: CGFloat = 0.2 private var irregularity: CGFloat = 0.2 let id = UUID() /// Owning `Shape` instances should use this to draw. var path: Path { Path(from: points) } init(pointsVector: [CGPoint], sideLength: CGFloat) { self.numberOfVertices = pointsVector.count self.points = pointsVector self.polygonPathPoints = points self.sideLength = sideLength } func drawn(asRandomizedPolygon: Bool) -> Self { var copy = self copy.drawsAsPolygon = asRandomizedPolygon copy.points = asRandomizedPolygon ? copy.polygonPathPoints : CGRect(x: 0, y: 0, width: 1, height: 1) .pointSequence(of: copy.numberOfVertices) return copy } func hash(into hasher: inout Hasher) { hasher.combine(id) } } /// A namespace around functionality to generate a path drawn in a 1x1 square /// with configurable "irregularity" and "spikiness". /// The closer both are to zero, the closer the generated polygon is to a /// [regular polygon](https://mathworld.wolfram.com/RegularPolygon.html) private enum UnitPolygonGeometryFactory { /// The maximum possible radius. A value of 0.5 restricts the algorithm /// to the unit square. private static let maxRadius: CGFloat = 0.5 /// A — by no means definitive — algorithm for creating an arbitrary /// polygon of `vertexCount` vertices /// - Parameters: /// - vertexCount: How many vertices (and edges) the polygon will have /// - irregularity: A subjective term for how "irregular" the polygon is. /// A fully regular polygon has all equal sides, assuming 0 `spikinesss`. /// - spikiness: A subjective term for how "spiky" the polygon is. /// A polygon with high spikiness will have more vertices closer and /// farther from where the vertex would be on a regular polygon. /// - Returns: An array of points representing the point-based path of /// the polygon static func random(vertexCount: Int, irregularity: CGFloat = 0.2, spikiness: CGFloat = 0.2) -> [CGPoint] { let floatVertices = CGFloat(vertexCount) // Irregularity is how much we're willing to allow the angular steps to // vary from "perfect". For example, in a regular (all sides equal) // six-sided polygon, each angular step is 2𝜋 / 6. Irregularity // defines the range that value can take, centered around a mean of // 2𝜋 / 6. We accept an irregularity between 0 and 1, and then // scale it for how much that represents out of a circle's radians. let scaledIrregularity = irregularity * 2.0 * CGFloat.pi / floatVertices // Spikiness describes how often we want to see values that are very // far from where a vertex of a regular polygon would be. For example, // a high positive spikiness might push a vertex radially very far from // the center, leading to a big "spike". Meanwhile, a spikiness of 0 // will yield more circular polygons. let denormalizedSpikiness = spikiness * maxRadius let gaussian = GKGaussianDistribution( randomSource: GKARC4RandomSource(), mean: Float(maxRadius * 1024), deviation: Float(denormalizedSpikiness * 1024)) // Generate the angular steps var raidanAngleSteps: [CGFloat] = [] // Both of these measured in radians let minimumSliceWidth = (2.0 * CGFloat.pi / floatVertices) - scaledIrregularity let maximumSliceWidth = (2.0 * CGFloat.pi / floatVertices) + scaledIrregularity var sum: CGFloat = 0 for _ in (0..<vertexCount) { let radians = CGFloat .random(in: minimumSliceWidth...maximumSliceWidth) raidanAngleSteps.append(radians) sum += radians } // Re-divide these steps so the point 0 and n+1 are the same. // I.e. if the random angle generation from the above loop yielded // more or less than 2𝜋 radians, reapportion those divisions to sum to // 2𝜋. let k = sum / (2 * CGFloat.pi) (0..<vertexCount).forEach { i in raidanAngleSteps[i] /= k } let maximumPossibleGaussianSample = CGFloat( gaussian.mean + Float(denormalizedSpikiness * 1024)*3 ) // Finally, make all of the normalized points within a 1x1 square // Unlike the unit circle of traditional geometry, because (0, 0) is in // the top left, (0.5, 0.5) is in the middle. Thus, positively // incrementing the angle moves us clockwise around the circle var points: [CGPoint] = [] let center = CGPoint(x: maxRadius, y: maxRadius) var cumulativeAngle: CGFloat = 0.0 for i in (0..<Int(vertexCount)) { // * 2 to keep the sample <= 0.5 (`maxRadius) let radiusForPoint = CGFloat(gaussian.nextInt()) / (maximumPossibleGaussianSample * 2) let x = center.x + radiusForPoint * cos(cumulativeAngle) let y = center.y + radiusForPoint * sin(cumulativeAngle) points.append(CGPoint(x: x, y: y)) cumulativeAngle += raidanAngleSteps[i] } return points } } // MARK: Observable Polygon Model /// A `PolygonModel` describes a collection of randomized ``Polygons`` that /// can be laid out by `AnyLayout` type. private class PolygonModel: ObservableObject { static let total = (maxSides - minSides + 1) * polygonsPerSideCount /// The minimum sides the randomly generated sides will have private static let minSides = 4 /// The maximum sides the randomly generated sides will have private static let maxSides = 7 /// The number of randomly generated polygons to make _per side length_. private static let polygonsPerSideCount = 32 /// All `PolygonGeometry`s that are laid out with `scatteredLayout` @Published var polygonGeometries: [PolygonGeometry] = makeGeometries() /// If `true`, `self` is expressing a grid layout with rectangular tiles. var tiled: Bool { usesGridLayout && !drawAsRandomPolygons } /// If `true`, ignore `scatteredLayout` and instead use a `Grid` layout @Published var usesGridLayout: Bool = false /// If `true`, `polygonGeometries` draw themselves as randomized polygons. /// If false, a rectangle that fills all available space. @Published var drawAsRandomPolygons: Bool = true { didSet { polygonGeometries = polygonGeometries.map { $0.drawn(asRandomizedPolygon: drawAsRandomPolygons) } } } /// Tunable by clients to experiment with different values. let spikiness: CGFloat = 0.2 /// Tunable by clients to experiment with different values. let irregularity: CGFloat = 0.2 /// Creates many ``PolygonGeometry`` instances with the given parameters. /// - Parameters: /// - irregularity: A subjective term for how "irregular" the polygon is. /// A fully regular polygon has all equal sides, assuming 0 `spikinesss`. /// - spikiness: A subjective term for how "spiky" the polygon is. /// A polygon with high spikiness will have more vertices closer and /// farther from where the vertex would be on a regular polygon. /// - Returns: An array of `n` polygons where `n` is defined by the /// `PolygonModel` class. private static func makeGeometries( irregularity: CGFloat = 0.3, spikiness: CGFloat = 0.3) -> [PolygonGeometry] { var scales: Array<CGFloat> = polygonSizeRatios .reduce(into: []) { partialResult, sizeRatio in let (size, percentage) = sizeRatio let scalesToMake = Int(ceil(percentage * CGFloat(total))) partialResult.append(contentsOf: (0..<scalesToMake) .map { _ in CGFloat.random(in: size.sizeRange) }) }.shuffled() return (minSides...maxSides).flatMap { vertexCount in return (0..<polygonsPerSideCount).map { _ in let unitPolygon = UnitPolygonGeometryFactory .random(vertexCount: vertexCount, irregularity: irregularity, spikiness: spikiness) let polygonGeometry = PolygonGeometry( pointsVector: unitPolygon, sideLength: scales.removeFirst()) return polygonGeometry } }.shuffled() } /// Complete remove and regenerate all model data. func reset() { polygonGeometries.removeAll(keepingCapacity: true) polygonGeometries = PolygonModel.makeGeometries( irregularity: irregularity, spikiness: spikiness ) } } private extension PolygonModel { /// Use a sampling of various sized polygons enum PieceSize: Hashable { case tiny case small case medium case large /// The range for the side length of the bounding rect of a polygon var sizeRange: ClosedRange<CGFloat> { switch self { case .tiny: return 16.0...25.0 case .small: return 25.0...40.0 case .medium: return 40.0...50.0 case .large: return 50.0...65.0 } } } /// This dictionary denotes the ratio of sizes to use. /// - warning: Should sum to 100. private static let polygonSizeRatios: [PieceSize: CGFloat] = [ .large: 0.15, .medium: 0.25, .small: 0.25, .tiny: 0.35 ] } // MARK: - Utility Extensions extension FloatingPoint { /// - returns an instance of `Self` clamped to the ``ClosedRange``. func clamped(to limits: ClosedRange<Self>) -> Self { return min(max(self, limits.lowerBound), limits.upperBound) } /// - returns an instance of `Self` clamped to the ``Range``. /// - note the value returned will be less than the provided upper bound, as /// is dictated by ``Range``. func clamped(to limits: Range<Self>) -> Self { return min(max(self, limits.lowerBound), limits.upperBound.nextDown) } } extension CGRect { /// Creates a rectangular sequence of `vertexCount `points denoting a /// rectangular path. /// - note This is helpful for animating a `Path` composed of `vertexCount` /// points into a ``Rectangle``. func pointSequence(of vertexCount: Int) -> [CGPoint] { // Start at a random corner. When many Polygons are using this // animation at once, if they all start at the same corner, an // unnatural uniformity of motion emerges. var startingPercent = [0, 0.25, 0.5, 0.75].randomElement()! var points: [CGPoint] = [] let extraPoints = vertexCount - 4 let (groups, remainder) = extraPoints .quotientAndRemainder(dividingBy: 3) for edge in 0...3 { points.append(pointAlongPerimeter(at: startingPercent)) for i in (0..<(edge == 3 ? remainder : groups)) { points.append(pointAlongPerimeter( at: startingPercent + 0.25 / CGFloat(groups + 1) * CGFloat(i))) } startingPercent += 0.25 startingPercent.formTruncatingRemainder(dividingBy: 1) } assert(points.count == vertexCount) return points } /// Returns the ``CGPoint`` that is `percent` along the path of `self`, /// with 0% mapping to the top-left corner, progressing clockwise. /// E.g. 50% would map to the bottom right corner if and only if `self` is /// a square. /// - Parameters: /// - percent: A percentage between `0.0` and `1.0` private func pointAlongPerimeter(at percent: CGFloat) -> CGPoint { let perimeter = size.width * 2 + size.height * 2 // Mark the four corners as percentages around the rect. For example, /// these values for a square would be 25%, 50%, 75%, 100% let topRight = size.width / perimeter let bottomRight = topRight + (size.height / perimeter) let bottomLeft = bottomRight + (size.width / perimeter) let topLeft = 1.0 switch percent { case 0..<topRight: return CGPoint( x: percent / topRight * size.width, y: minY) case topRight..<bottomRight: return CGPoint( x: maxX, y: (percent - topRight) / (bottomRight - topRight) * size.height) case bottomRight..<bottomLeft: return CGPoint( x: maxX - ((percent - bottomRight) / (bottomLeft - bottomRight) * size.width), y: maxY) case bottomLeft...topLeft: return CGPoint( x: minX, y: maxY - (percent - bottomLeft) / (topLeft - bottomLeft) * size.height ) default: preconditionFailure("Invalid percentage requested") } } } /// Returns a new `CGRect` with the same size as `self`, but centered in `other` /// vertically, and horizontally. extension CGSize { func centered(in other: CGRect) -> CGRect { CGRect(x: other.midX - width / 2.0, y: other.midY - height / 2.0, width: width, height: height) } } extension Path { /// Convenience for initializing a `Path` from an array of `CGPoint`s given /// the first point element is the `Path`'s first point. init(from points: [CGPoint]) { self.init() self.addLines(points) self.closeSubpath() } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.