스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI로 맞춤형 레이아웃 작성
SwiftUI는 이제 앱의 인터페이스 레이아웃 수준을 한 단계 높이고 보기를 정렬할 수 있는 강력한 도구를 제공합니다. 고도로 맞춤화가 가능한 2차원 레이아웃을 만들 수 있는 그리드 컨테이너를 소개하고, 레이아웃 프로토콜을 사용하여 완전한 맞춤형 동작으로 나만의 컨테이너를 빌드하는 방법을 보여드립니다. 또한 레이아웃 유형 사이에 원활한 애니메이션 전환을 만드는 방법을 살펴보고, 우수한 인터페이스를 만들기 위한 팁과 모범 사례를 공유합니다.
리소스
관련 비디오
WWDC23
WWDC22
WWDC20
WWDC19
-
다운로드
♪ ♪
안녕하세요 'SwiftUI로 커스텀 레이아웃 구성하기'입니다 저는 Paul이고 개발자용 서비스 문서를 작성하죠 SwiftUI는 앱의 인터페이스 구성에 필요한 다양한 구성 요소를 제공합니다 내장 뷰를 혼합해 텍스트, 이미지, 그래픽 등으로 커스텀 혼합 뷰를 만들어낼 수 있죠 이러한 요소를 훨씬 정교하게 배열할 수 있도록 SwiftUI가 레이아웃 도구를 제공합니다
수평 및 수직 스택과 같은 컨테이너를 이용하여 SwiftUI가 뷰의 상대적 위치를 결정하며 뷰 제어자를 통해 간격이나 정렬 같은 요소를 더 제어할 수 있죠 이 세션에서는 일반적인 레이아웃을 더 쉽게 구성하고 복잡한 레이아웃도 구성할 수 있는 새로운 도구를 소개하겠습니다 SwiftUI 레이아웃 작업에 관한 비법도 알려 드리죠 먼저 그리드의 새로운 요소를 통해 고정된 뷰를 나타내고 싶을 때 사용하기 알맞은 2차원 레이아웃을 보여 드리겠습니다 다음은 새로운 레이아웃 프로토콜을 활용하여 레이아웃 엔진과 직접 상호작용하여 커스텀 뷰 컨테이너를 만드는 법을 알려 드리죠 다음은 ViewThatFits에 관해 얘기할 건데 가용한 공간에 맞는 뷰 집합체를 자동으로 선택하는 컨테이너 유형입니다 마지막으로 AnyLayout을 이용하여 레이아웃 유형의 전환을 보여 드릴 겁니다 새로운 기능을 활용하는 걸 보기 위해 작업 중인 앱을 함께 살펴보죠
최근 몇 년간 동료들과 최고의 털북숭이 친구들에 관한 논쟁을 벌였습니다 저도 선호하는 동물이 있지만 다른 의견도 같은지 궁금해서 설문 조사할 수 있는 앱을 만들었죠 털에 알레르기가 있는 분들을 위해 선택지를 하나 추가했습니다 미리보기를 이용하여 프로토타입을 만들기가 쉬워서 SwiftUI의 인터페이스 디자인을 최대한 활용하려고 하지만 일단 시작할 때는 목표를 담은 스케치를 그렸죠 저는 투표가 오랫동안 진행될 거로 생각해서 중앙에 현재 순위를 보여 주는 리더보드를 추가했습니다 투표를 위한 버튼은 아래에 배치할 거예요 그리고 맨 위에는 선택지의 이미지를 보여 줄 겁니다
일단 리더보드를 만들고 싶으니까 그것부터 자세히 살펴보죠 리더보드는 2차원의 그리드로 각 행에 선택지가 있고 열에는 이름과 득표율 득표수를 보여 줍니다 제가 달성하려는 목적은 2개인데요 첫째는 2개의 텍스트 열의 폭을 최소화하기 위해서 같은 열의 항목 중에서 가장 긴 셀에 맞출 건데 득표율을 나타내는 진척도 뷰에 공간을 최대한 할애하고 싶기 때문이죠 득표수가 많아지거나 언어가 달라져도 이 원칙이 유지되어야 하며 다른 텍스트 크기에도 맞춰야 합니다 둘째, 이름은 좌측에 정렬하고 득표수는 우측에 정렬해야 하죠 SwiftUI에는 레이지 그리드가 있어 스크롤 콘텐츠에 적합합니다 이 컨테이너는 뷰가 많을 때 아주 효율적인데 현재 보이거나 곧 보일 뷰만 로딩하기 때문이죠 하지만 그로 인해 컨테이너가 셀의 높이와 폭을 자동으로 조정할 수 없습니다
예를 들어 LazyHGrid는 열의 폭을 계산할 수 있는데 열의 모든 뷰를 그리기 전에 계산할 수 있기 때문이죠 하지만 열의 모든 뷰를 측정하여 행의 높이를 계산할 수는 없습니다 그걸 가능하게 하려면 초기화 시점에 레이지 그리드에 높이나 폭의 정보를 제공해야 하죠
레이지 그리드나 SwiftUI의 레이아웃 컨테이너 유형을 보려면 2020년의 '스택, 그리드 아웃라인' 세션을 시청하십시오 저는 스크롤 할 필요가 없어서 SwiftUI가 각 셀의 높이와 폭을 계산하기를 바랍니다 이런 레이아웃을 위해 SwiftUI가 그리드 뷰를 제공하죠 레이지 그리드와는 달리 모든 뷰를 한 번에 로딩하여 열과 행에 맞춰서 자동으로 셀의 크기를 조정하고 정렬합니다 코드를 살펴보죠 이건 그리드로 작성한 리더보드의 기본 버전입니다 특정 그리드 뷰는 3개의 GridRow 인스턴스를 포함하죠 행 안의 각 뷰는 열에 해당합니다 이 예시에서 각 행의 첫 번째 텍스트 뷰는 첫 번째 열에 해당하고 두 번째 열은 진척도 뷰고 세 번째 열은 다음 텍스트 뷰죠 그리드가 각 행과 열에 최대한의 공간을 할당하여 최대의 뷰를 보여 줍니다 첫 번째 텍스트 열은 가장 긴 이름보다 길지 않죠 진척도 표시처럼 탄력적인 뷰는 공간을 최대로 할당했는데 텍스트 열에 배정한 뒤 남은 공간을 할당받았습니다 이걸 조정하고 싶지만 일단 기본 데이터 모델을 만들어서 득표수를 저장하죠
네트워크를 통해 공유하려면 데이터 로직이 더 필요하지만 인터페이스 프로토타입 단계에는 간단한 구조면 됩니다 Identifiable 컨포먼스를 포함하여 ForEach에서 사용하기 쉽게 하고 Equatable 컨포먼스를 사용하여 변화를 애니메이션으로 표현할게요
프로토타입의 미리보기에서 사용할 예시 데이터도 만들겠습니다 다시 그리드로 돌아가서 고정 변수를 생성하고 예시 데이터와 함께 초기화하죠 그 데이터를 이용하여 ForEach로 행을 만들 수 있습니다 렌더링 결과물은 바뀌지 않는데 데이터가 그대로이기 때문이죠 이미 목표에 근접했지만 셀의 정렬을 수정해야 합니다 현재 모든 셀이 중앙 정렬돼 있는데 그게 그리드의 기본값이죠 하지만 기억하신다면 저는 항목을 왼쪽에 정렬하고 싶고 득표수는 오른쪽에 정렬하고 싶습니다 이를 위해 왼쪽 정렬로 그리드를 초기화하죠 제가 여기에서 사용한 값이 그리드의 모든 셀에 적용됩니다 첫 두 열은 괜찮지만 마지막 열은 어떻게 할까요? 단일 열의 정렬을 바꾸려면 gridColumnAlignment 뷰 제어자를 열의 한 셀에 적용하면 됩니다 마지막 열의 텍스트 뷰에 그렇게 적용할게요 거의 근접했지만 제가 봤을 때는 행 사이에 분할 선이 있으면 좋겠습니다 ForEach를 이용하여 새 행에 분할 선을 추가하면 제가 원하는 모습이 아니지만 몇 가지 재미있는 사실을 알 수 있죠 먼저, 분할 선이 탄력적인 뷰여서 첫 번째 열에 더 많은 공간이 배정됐습니다 결국 그리드는 마지막 열에 공간을 할당하고 앞의 두 열에 남은 공간을 나누어 주었죠 둘째로 다른 행만큼 뷰가 많지 않은 행은 뷰가 나타나지 않아 이후 열이 빈칸으로 보입니다 하지만 저는 분할 선이 모든 열을 나누기를 바라죠 SwiftUI의 새로운 뷰 제어자로 제가 원하는 걸 할 수 있습니다
그리드 셀 열 제어자를 뷰에 추가하면 단일 뷰가 여러 행에 걸쳐 나타나게 할 수 있죠 이 경우에는 3개의 열입니다 뷰가 전체 그리드에 적용되어야 하면 간단하게 그리드 행 바깥에 뷰를 작성하면 되죠 이제 리더보드가 모습을 갖추고 있습니다 다음으로 투표에 사용될 버튼을 살펴보죠
얼핏 봤을 때는 어려울 게 없습니다 하지만 저에게는 특별한 요구 사항이 있죠 저는 투표 참여자들이 편견을 갖지 않도록 특정 선택지의 버튼이 작지 않았으면 좋겠지만 컨테이너의 크기에 맞게 버튼이 커지는 건 싫습니다 iPad나 Mac에서는 지나치게 커지니까요 가장 폭이 넓은 텍스트에 모든 버튼 폭을 맞춰야 합니다 이걸 Hstack으로 만들면 어떻게 될까요? 각 버튼이 텍스트 레이블에 맞게 크기를 조정하고 HStack은 이 버튼들을 수평으로 묶습니다 스택의 기본 행태는 대부분 사례에 적합하지만 저의 프로젝트 요구조건에는 맞지 않죠
SwiftUI의 레이아웃 기본 원리를 상기하고 싶으시면 'SwiftUI로 커스텀 뷰 만들기' 2019년 영상을 참고하세요 해당 영상의 개념을 활용하여 뷰 계층 구조를 살펴보고 제가 원하는 행태를 위한 수정 사항을 알아보죠
먼저 스택의 컨테이너가 스택에 크기를 제안하죠 이걸 토대로 스택은 3개 버튼에 크기를 제안하고 각 버튼은 해당 크기를 텍스트 레이블에 전달합니다 텍스트 뷰는 실제로 원하는 크기를 계산하는데 뷰에 담긴 스트링을 바탕으로 계산하여 이걸 버튼에 보고하죠 버튼은 그 정보를 다시 위로 올립니다 스택은 이 정보를 토대로 크기를 변경하고 각 공간에 버튼을 배치한 뒤 스택의 크기를 컨테이너에 올리죠 버튼이 텍스트에 맞게 크기를 바꾼다면 텍스트 뷰를 유연한 틀로 감싸 늘어날 수 있게 하면 어떨까요? 텍스트는 바뀌지 않았지만 버튼에 유연한 서브 뷰가 있어 HStack이 제공하는 최대 공간을 차지합니다 스택은 안에 포함한 뷰에 공간을 균등하게 배분하죠 버튼이 크기가 같아진 건 좋지만 실제 크기는 스택의 컨테이너에 좌우됩니다 컨테이너의 공간을 채우기 위해 스택이 늘어날 수 있는데 그건 제가 원하는 게 아니죠 제가 정말 원하는 것은 커스텀 스택 유형으로 각 버튼의 이상적인 크기를 요청하는 겁니다 가장 폭이 넓은 버튼의 크기를 다른 버튼에도 제공하는 거죠 다행히도 SwiftUI에 이게 가능한 도구가 생겼죠 레이아웃 프로토콜의 커스텀 레이아웃 컨테이너에서 레이아웃 절차에 직접 관여하여 저의 유즈케이스에 맞춘 행태를 정의할 수 있습니다 원리를 살펴보죠 HStack으로 돌아가서 EqualWidthHStack으로 바꿀게요 저의 문제를 해결하기 위해 정의할 유형이죠 이 유형은 각 버튼의 폭을 균등하게 배분합니다 가장 넓은 버튼의 폭에 맞추는 거죠 유연한 틀은 유지하여 폭이 좁은 텍스트의 버튼이 스택이 제공하는 공간 안에서 확장할 수 있습니다 하지만 버튼은 제가 계산한 크기를 따르죠 바로 텍스트의 폭입니다 이제 myEqualWidthHStack을 실제로 적용해 보죠
레이아웃 프로토콜에 맞춘 유형을 만들어 시작합니다 기본 레이아웃에는 2개의 필수 메서드만 있으면 되죠 메서드의 스텁을 추가합시다 첫 번째 메서드는 sizeThatFits이며 레이아웃 컨테이너의 크기를 계산하고 보고하죠
뷰 크기 입력값의 제안치를 받는데 레이아웃의 컨테이너 뷰에서 제안하는 크기입니다 그리고 Subviews 매개 변수로 서브 뷰에도 크기를 제안하죠
서브 뷰에 직접 접근할 수는 없습니다 대신 서브 뷰 입력값은 프락시의 집합으로 서브 뷰와 특정한 방식으로 상호작용하게 해 주죠 크기 제안도 그렇습니다 각 프락시는 제안을 바탕으로 정확한 크기를 반환하죠 저는 반환된 값을 수집하여 계산에 활용하고 EqualWidthHStack의 정확한 크기를 컨테이너에 반환합니다
두 번째로 적용하는 메서드는 placeSubviews죠 레이아웃의 서브 뷰에 나타나는 곳을 지정합니다 이 메서드에도 제안과 서브 뷰 입력값이 있고 영역을 나타내는 테두리 입력값은 서브 뷰의 배치를 위해 필요하죠 테두리는 sizeThatFits 함수에서 제가 요청했던 사각형의 크기입니다 SwiftUI에서는 뷰가 자기 크기를 정하므로 레이아웃 컨테이너는 요청한 크기가 되죠 영역의 시작점은 왼쪽 위에 있고 양수 X값은 오른쪽이고 양수 Y값은 아래쪽이죠 위치를 계산할 때 이걸 기본으로 생각하세요 우에서 좌로 향하는 언어 환경도 마찬가지인데 프레임워크가 자동으로 각 뷰의 X 위칫값을 변환하여 해당 방향의 뷰를 배치하기 때문이죠 하지만 사각형의 시작점 벡터가 (0,0)이라고 가정하지 마세요 0이 아닌 시작점을 허용하면 레이아웃 구성을 할 수 있어 특정 레이아웃의 서브 뷰 배치 메서드가 다른 레이아웃의 같은 메서드를 호출할 수 있죠 작업의 편의성을 위해 사각형이 속성을 제공하여 영역의 주요 부분에 접근할 수 있습니다 예를 들어 각 차원의 최소, 중앙, 최대 지점이죠
더 진행하기 전에 두 메서드에 모두 있는 매개 변수를 보십시오 양방향 캐시인데 메서드 호출에 걸친 중간 계산값을 공유하죠 간단한 레이아웃 대부분에는 캐시가 필요 없어서 저도 캐시는 다루지 않겠지만 Instruments로 프로파일링하면 레이아웃 코드의 효율성을 개선할 필요가 있으므로 추가하는 게 좋을 겁니다 관련 정보가 있는 문서를 확인해 보십시오
이제 sizeThatFits를 적용합시다 기억하세요 컨테이너의 크기를 반환하여 같은 폭의 버튼을 수평으로 배열하려고 합니다 그러기 위해 각 버튼에 크기를 물어볼 건데 일단 크기를 제안하고 반환되는 값을 보죠 서브 뷰의 유연성을 측정하기 위해서 특별히 제안한 최솟값과 최댓값, 이상적인 크기를 이용해 여러 번 측정할 수 있습니다 아니면 특정 크기를 제안할 수도 있죠 여기서는 크기를 제안하지 않고 이상적인 크기를 물어봅니다
그리고 모든 크기의 각 차원에서 가장 큰 값을 찾아 반환하죠 이 경우에는 금붕어 버튼이 폭을 설정하고 높이는 전부 같습니다 이걸 다시 메서드에 적용하는데 서브 뷰를 배치할 때 다시 사용할 수치죠 다음은 뷰 사이의 간격을 고려해야 합니다 10포인트와 같이 일정한 간격을 써도 되지만 레이아웃 프로토콜로 더 좋게 할 수 있죠 SwiftUI의 모든 뷰에는 간격 설정이 있어 현재 뷰와 다음 뷰 사이의 공간을 지정할 수 있습니다 이러한 설정값은 ViewSpacing 인스턴스에 저장하며 레이아웃 컨테이너에서 사용할 수 있죠 뷰의 간격은 테두리마다 다를 수도 있고 서로 이웃한 뷰의 종류에 따라 달라질 수도 있습니다 예를 들어 뷰와 텍스트 뷰의 간격과 뷰와 이미지와의 간격을 다르게 설정하고 싶을 수 있죠 플랫폼에 따라 값이 달라질 수도 있습니다 여러분의 레이아웃에 맞는다면 설정하지 않아도 됩니다 커스텀 간격을 설정한 내장된 스택을 초기화하면 벌어지는 일이니까요 하지만 이러한 설정을 레이아웃에 적용하면 Apple의 인터페이스 가이드라인을 자동으로 따를 수 있죠 시스템의 다른 부분과 외형을 맞출 수 있습니다 모든 뷰는 테두리마다 설정할 수 있지만 2개의 뷰를 한데 모았을 때 맞닿은 테두리 설정이 맞지 않을 수 있죠 이를 해결하기 위해 내장된 레이아웃 컨테이너가 두 설정 중 더 큰 값을 사용합니다 제 레이아웃에도 동일하게 적용할 수 있죠
서브 뷰 프락시로 한 축에서 각 버튼이 선호하는 간격을 물어볼 수 있습니다 서브 뷰를 탐색하며 값의 배열을 만들고 각 프락시의 간격 인스턴스의 거리 메서드를 호출하여 수평축의 다음 뷰의 간격 인스턴스에 간격 값을 전달하는 거죠 이 호출은 테두리를 공유하는 2개 뷰의 설정을 고려합니다 이 배열의 첫 번째 요소는 고양이 버튼이 금붕어 버튼과 두고 싶은 간격이고 그다음은 금붕어 버튼이 개 버튼과 두고 싶은 간격이죠 배열의 마지막 요소는 0으로 고정하겠습니다 더 비교할 수 있는 버튼이 없으니까요 이걸 나중의 메서드에도 다시 고려할 겁니다 이제 간격 값을 더하여 총 간격을 구한 뒤 폭과 높이 측정값과 종합하여 크기 값을 반환할 거예요 이게 레이아웃에서 요구하는 크기죠 서브 뷰의 이상적인 크기와 각 서브 뷰가 선호하는 간격을 반영했으니까요 제가 적용해야 하는 다른 메서드는 placeSubviews죠 앞에서 언급한 것처럼 컨테이너의 경계와 서브 뷰 프락시의 집합으로 버튼을 배치할 수 있습니다 먼저 maxSize와 간격 배열을 계산할 건데 sizeThatFits 메서드에서 했던 것과 같죠 여기에도 그 값들이 필요합니다 그다음에 각 서브 뷰에 사용할 크기를 제안할 건데 이상적인 크기가 아니라 제가 원하는 크기를 기반으로 할 거예요 제안은 하나면 됩니다 버튼 크기를 통일할 거니까요 이제 첫 번째 서브 뷰의 시작 지점을 정할 건데 테두리의 왼쪽 끝에서 시작하여 버튼 폭의 절반을 더하죠 저는 시작점을 0으로 가정하지 않고 minX 값을 대신 사용했습니다 마지막으로 각 서브 뷰의 프락시를 거쳐 place 메서드를 지점으로 호출할 건데 이는 버튼 내에서의 지점의 위치와 크기 제안을 담고 있죠 루프를 지날 때마다 수평 위치를 뷰의 폭과 다음 간격을 더해 업데이트하면서 다음 반복에 사용합니다 이게 끝이에요 새로운 뷰 레이아웃 유형을 사용하면 어떻게 되는지 보죠
이렇게 됩니다 커스텀 레이아웃 컨테이너를 내장된 HStack과 같이 인스턴스화하고 버튼은 모두 같은 폭으로 가로 배열합니다 이제 여기서 잠시 멈추고 Layout 프로토콜의 문제를 해결을 논할게요 과거에는 지오메트리 리더를 사용했을 겁니다 지오메트리 리더는 뷰 크기를 측정하는 도구지만 이 경우에는 좋은 선택지가 아니죠 지오메트리 리더는 컨테이너 뷰를 측정하여 그 크기를 서브 뷰에 보고하기 때문입니다 서브 뷰는 그 정보를 이용하여 자기 콘텐츠를 그리죠 지오메트리 리더의 의도된 사용에 따르면 정보가 아래로 흐릅니다 리더가 실행하는 측정값은 컨테이너의 레이아웃에 영향을 주지 못하죠
컨테이너와 함께 크기가 바뀌는 경로를 그릴 때 좋습니다 지오메트리 리더는 경로 로직에 필요한 공간을 통보하고 서브 뷰의 경로 로직은 그에 맞춰 조정하죠 컨테이너의 크기가 바뀌면 경로의 크기도 바뀝니다 지오메트리 리더가 새 크기를 전달하니까요 다시 하나의 버튼을 생각해 보죠 버튼이 잘 보이려면 텍스트 뷰를 측정하고 그걸 텍스트 뷰의 컨테이너인 프레임 설정에 사용합니다 여기에 텍스트 뷰의 오버레이로 지오메트리 리더를 넣을 수 있지만 이는 컨테이너를 측정하죠 측정 데이터를 정상적인 흐름과 달리 위로 올려야 합니다 하지만 이렇게 하면 레이아웃 엔진을 우회하고 루프로 이어질 수 있죠 리더가 레이아웃을 측정하여 프레임을 바꾸면 레이아웃이 바뀌어서 다시 측정하는 게 반복됩니다 이걸 사용해서 작업할 수도 있지만 조심하지 않으면 앱이 충돌할 수 있죠 따라서 이 전략은 추천하지 않습니다 다행히도 레이아웃 프로토콜의 레이아웃 엔진 안에서 문제를 해결할 수 있는 방법이 있죠 다시 버튼을 살펴봅시다 제가 하고 싶은 다른 작업이 있습니다 일단 가독성을 높이고 싶어요 버튼을 서브 뷰 안에 다시 설정하겠습니다 제 동료 중에 기기에서 큰 서체를 사용하는 분이 있죠 제 앱은 기본 폰트를 사용하여 Dynamic Type을 자동 지원해서 무료로 올바른 행태를 적용하고 있습니다 글꼴 크기를 키우면 어떻게 되는지 보죠 이런, 버튼이 맞지 않습니다 커스텀 스택이 버튼의 폭을 제한하지 않고 이상적인 크기로 바꾸죠 이 경우에는 디스플레이의 폭을 초과했습니다 그러면 어떻게 할까요? 레이아웃을 수정하여 더 복잡한 기능을 수행하도록 하여 뷰가 맞지 않을 때 레이아웃 컨테이너의 크기 제안을 고려할 수 있죠 하지만 이 경우에는 viewThatFits를 사용하면 거의 모든 작업을 처리할 수 있습니다 이 새로운 기능은 제가 제공하는 뷰의 목록에서 가용 공간에 맞는 첫 번째 뷰를 선택하죠
커스텀 스택을 viewThatFits 구조에 넣고 같은 콘텐츠의 수직 스택 버전을 추가하면 필요시 SwiftUI가 알아서 버튼을 다르게 배열합니다 물론 내장된 VStack은 저의 커스텀 수평 스택처럼 균등한 폭을 지정하는 속성이 없으므로 커스텀 스택의 수직 버전도 적용했죠 제가 이미 설명한 것과 아주 비슷하지만 폭이 균등한 항목을 수평축이 아닌 수직축을 기준으로 배열합니다
물론 동적 서체 크기를 삭제하면 수평 레이아웃으로 돌아가죠 이제 앱의 마지막 부분인 상단의 이미지를 제작하겠습니다 간단하게 동물 사진을 늘어놓을 수도 있지만 살짝 재미를 주고 싶었죠 그래서 뷰를 원형 배열로 그리는 다른 커스텀 레이아웃 유형을 만들었죠 순위에 따라 배열이 회전하는 방식입니다 이 구성에서는 금붕어가 1등이고 나머지 둘이 공동 2위죠 만약 개가 고양이를 앞서면 조금 회전하면 됩니다 아니면 조금 더 현실적인 결과도 원형 레이아웃을 회전시키면 보여 줄 수 있죠 이 레이아웃을 만드는 건 직관적 레이아웃 절차를 따릅니다 이전처럼 메서드가 2개 필요하죠 sizeThatFits의 경우 뷰가 가용 공간을 채우도록 하여 컨테이너 뷰가 제안하는 크기를 반환합니다 저는 제안을 실제 크기로 변환할 건데 replacingUnspecifiedDimensions 메서드를 사용하죠 이 메서드는 컨테이너가 이상적인 크기를 물었을 때 발생할 수 있는 nil 값을 자동으로 처리합니다 서브 뷰 배치 메서드 안에는 레이아웃 영역의 크기를 바탕으로 계산한 반지름값만큼 중앙으로부터 떨어지게 하고 뷰의 인덱스에 따라 회전하도록 적용하죠 기준점과 원둘레의 1/3과 2/3 지점에 이미지를 배치하는 겁니다 현재 순위를 반영하기 위해 모든 뷰에 똑같이 영향을 주는 오프셋을 적용할 거죠 순위는 어디서 받을까요? 제 레이아웃은 서브 뷰 프락시만 접근할 수 있고 다른 뷰나 데이터 모델에는 접근할 수 없죠 알고 보니 레이아웃 프로토콜에 또 다른 기능이 있었습니다 각 서브 뷰에 값을 저장할 수 있고 레이아웃 프로토콜 메서드 안에서 값을 읽을 수 있죠 이를 이용하여 순위 정보를 전달해 봅시다 먼저 LayoutValueKey 프로토콜에 맞는 새 유형을 선언하고 기본값을 설정하죠 명시적으로 설정하지 않은 뷰에 값을 제공하고 기본값이 연관된 수치의 유형을 설정합니다 이 경우는 정수죠 그리고 layoutValue 뷰 제어자를 이용하여 뷰에 편의 메서드를 만들고 값을 설정합니다 이제 뷰의 계층 구조에서 레이아웃의 뷰에 순위 제어자를 적용할 수 있죠 여기서 각 동물의 순위를 계산하여 원형 레이아웃 안에 해당 동물의 뷰를 추가합니다 마지막으로 서브 뷰 배치 메서드에서 레이아웃 값 키를 인덱스로 사용하여 각 서브 뷰의 값을 읽는 코드를 추가할게요 이제 순위를 이용하여 오프셋을 계산할 수 있습니다 원리를 설명하지는 않겠지만 모든 순위에 맞춰 각도를 조절하도록 설계했죠 하나의 경우만 제외하고요 공동 1위가 셋이면 어떻게 될까요? 레이아웃을 어떻게 회전해도 모든 뷰를 일직선에 둘 수 없죠 그러려면 레이아웃의 원리를 완벽하게 대체해야 하는데 이미 이게 가능한 레이아웃 유형이 있습니다 바로 내장된 HStack이죠 따라서 공동 1위가 셋이면 HStack으로 전환할 겁니다 전환을 위한 새로운 도구도 생겼죠 AnyLayout 유형은 단일 뷰 계층 구조에서 다른 레이아웃을 적용하여 레이아웃 유형을 넘나들 때 뷰의 정체성을 유지하도록 합니다 아까 봤던 원형 레이아웃이 있는데 공동 1등이 셋일 때 새로운 레이아웃 유형으로 대체하면 되죠 isThreeWayTie 속성은 상태에서 비롯되므로 SwiftUI는 속성의 변화를 감지하고 언제 뷰를 다시 그릴지 이해합니다 하지만 뷰 계층의 구조적 정체성은 그대로 유지되므로 SwiftUI는 새로운 뷰가 아니라 뷰가 변했다고 인식하죠 따라서 코드를 한 줄만 추가하면 레이아웃 유형을 매끄럽게 전환할 수 있습니다 애니메이션 뷰 제어자를 추가하면 원형 레이아웃의 상태가 바뀔 때 애니메이션으로 표현할 수 있죠 원형 레이아웃 설정이 같은 데이터를 사용하니까요 실제로 적용한 모습입니다 버튼을 눌러 득표수를 바꿀 때마다 현재 순위를 반영하기 위해 아바타가 움직이죠
앱 뷰 레이아웃을 구성할 수 있는 SwiftUI의 새로운 도구를 살펴봤습니다 그리드 유형을 이용하여 커스텀 설정으로 정보를 2차원 레이아웃으로 표현할 수 있죠 레이아웃 프로토콜을 사용하여 재사용할 수 있는 일반 목적에 사용하거나 맞춤형 레이아웃을 만드세요 ViewThatFits를 사용하면 SwiftUI가 가용 공간에 가장 잘 맞는 뷰를 선택합니다 AnyLayout을 이용하여 레이아웃 간 전환도 할 수 있죠 시청해 주셔서 감사합니다 새로운 레이아웃 도구에 관해 저만큼 재미있으셨기를 바랍니다
-
-
4:28 - Grid with explicit rows
struct Leaderboard: View { var body: some View { Grid { GridRow { Text("Cat") ProgressView(value: 0.5) Text("25") } GridRow { Text("Goldfish") ProgressView(value: 0.2) Text("9") } GridRow { Text("Dog") ProgressView(value: 0.3) Text("16") } } } }
-
5:16 - Data model
struct Pet: Identifiable, Equatable { let type: String var votes: Int = 0 var id: String { type } static var exampleData: [Pet] = [ Pet(type: "Cat", votes: 25), Pet(type: "Goldfish", votes: 9), Pet(type: "Dog", votes: 16) ] }
-
5:41 - Final Leaderboard
struct Leaderboard: View { var pets: [Pet] var totalVotes: Int var body: some View { Grid(alignment: .leading) { ForEach(pets) { pet in GridRow { Text(pet.type) ProgressView( value: Double(pet.votes), total: Double(totalVotes)) Text("\(pet.votes)") .gridColumnAlignment(.trailing) } Divider() } } .padding() } }
-
10:53 - Layout protocol stubs for required methods
struct MyEqualWidthHStack: Layout { func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout Void ) -> CGSize { // Return a size. } func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void ) { // Place child views. } }
-
13:44 - Maximum size helper method
private func maxSize(subviews: Subviews) -> CGSize { let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) } let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in CGSize( width: max(currentMax.width, subviewSize.width), height: max(currentMax.height, subviewSize.height)) } return maxSize }
-
15:40 - Spacing helper method
private func spacing(subviews: Subviews) -> [CGFloat] { subviews.indices.map { index in guard index < subviews.count - 1 else { return 0 } return subviews[index].spacing.distance( to: subviews[index + 1].spacing, along: .horizontal) } }
-
16:33 - Size that fits implementation
func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout Void ) -> CGSize { // Return a size. guard !subviews.isEmpty else { return .zero } let maxSize = maxSize(subviews: subviews) let spacing = spacing(subviews: subviews) let totalSpacing = spacing.reduce(0) { $0 + $1 } return CGSize( width: maxSize.width * CGFloat(subviews.count) + totalSpacing, height: maxSize.height) }
-
16:51 - Place subviews implementation
func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void ) { // Place child views. guard !subviews.isEmpty else { return } let maxSize = maxSize(subviews: subviews) let spacing = spacing(subviews: subviews) let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height) var x = bounds.minX + maxSize.width / 2 for index in subviews.indices { subviews[index].place( at: CGPoint(x: x, y: bounds.midY), anchor: .center, proposal: placementProposal) x += maxSize.width + spacing[index] } }
-
18:07 - Custom layout instantiation
MyEqualWidthHStack { ForEach($pets) { $pet in Button { pet.votes += 1 } label: { Text(pet.type) .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } }
-
20:12 - Buttons helper view
struct Buttons: View { @Binding var pets: [Pet] var body: some View { ForEach($pets) { $pet in Button { pet.votes += 1 } label: { Text(pet.type) .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } } }
-
21:08 - Final voting buttons view
struct StackedButtons: View { @Binding var pets: [Pet] var body: some View { ViewThatFits { MyEqualWidthHStack { Buttons(pets: $pets) } MyEqualWidthVStack { Buttons(pets: $pets) } } } }
-
22:30 - Radial size that fits
func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout Void ) -> CGSize { // Take whatever space is offered. return proposal.replacingUnspecifiedDimensions() }
-
22:52 - Radial place subviews without offsets
func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void ) { let radius = min(bounds.size.width, bounds.size.height) / 3.0 let angle = Angle.degrees(360.0 / Double(subviews.count)).radians let offset = 0 // This depends on rank... for (index, subview) in subviews.enumerated() { var point = CGPoint(x: 0, y: -radius) .applying(CGAffineTransform( rotationAngle: angle * Double(index) + offset)) point.x += bounds.midX point.y += bounds.midY subview.place(at: point, anchor: .center, proposal: .unspecified) } }
-
23:42 - Rank value
private struct Rank: LayoutValueKey { static let defaultValue: Int = 1 } extension View { func rank(_ value: Int) -> some View { layoutValue(key: Rank.self, value: value) } }
-
24:21 - Radial place subviews with offsets
func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void ) { let radius = min(bounds.size.width, bounds.size.height) / 3.0 let angle = Angle.degrees(360.0 / Double(subviews.count)).radians let ranks = subviews.map { subview in subview[Rank.self] } let offset = getOffset(ranks) for (index, subview) in subviews.enumerated() { var point = CGPoint(x: 0, y: -radius) .applying(CGAffineTransform( rotationAngle: angle * Double(index) + offset)) point.x += bounds.midX point.y += bounds.midY subview.place(at: point, anchor: .center, proposal: .unspecified) } }
-
25:18 - Final profile view
struct Profile: View { var pets: [Pet] var isThreeWayTie: Bool var body: some View { let layout = isThreeWayTie ? AnyLayout(HStackLayout()) : AnyLayout(MyRadialLayout()) Podium() // Creates the background that shows ranks. .overlay(alignment: .top) { layout { ForEach(pets) { pet in Avatar(pet: pet) .rank(rank(pet)) } } .animation(.default, value: pets) } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.