Unsplash에 Tirza van Dijk의 사진소개
먼저 SwiftUI를 좋아한다고 말씀 드리겠습니다! 내가 SwiftUI의 팬인 이유는 무엇입니까? 명령 적 (인터페이스 빌더) 접근 방식보다 인터페이스 개발에 대한 선언적 접근 방식을 선호합니다. SwiftUI를 사용하면 iOS 시뮬레이터를 실행하지 않고도 디자인 아이디어를 더 빠르게 반복하고 결과를 볼 수 있습니다. 내 팬보이 선언을 중단하고 SwiftUI 개발 프로세스를 살펴 보겠습니다.
참고 : 이것은 SwiftUI 튜토리얼이나 SwiftUI에 대한 자세한 소개가 아닙니다. developer.apple.com 및 raywenderlich.com 에는 이러한 정보에 대한 유용한 리소스가 많이 있습니다 . 내 기사와 코드 예제에서는 SwiftUI, SwiftUI 데이터 흐름 / 상태 관리 및 MVVM (Model-View-ViewModel) 디자인 패턴에 대한 몇 가지 기본 지식을 가정합니다.
어떻게 시작합니까?
내 시리즈 Part 3의 사용자 경험 (UX) 디자인과 와이어 프레임을 기억하십니까? 와이어 프레임은 나의 출발점 역할을합니다. 저의 기본적인 사용자 인터페이스 개발 철학은 각 와이어 프레임이 화면을 구성하는 하위 섹션 / 구성 요소 인 "보기"로 구성된 SwiftUI "화면"이라는 것입니다. 와이어 프레임의 SwiftUI 구현을 빌드 한 다음 화면을 구성하는 뷰로 필요에 따라 화면을 리팩터링하여 코드를 더 잘 정리했습니다.
또한 필요한 SwiftUI 코드를 개발하는 데 필요한 ViewModel 및 JSON 데이터 모델의 골격을 만듭니다. 예를 들어 앱은 Flickr의 사진 스트림을 표시하므로 Flickr 포토 스트림에서 사진을 디코딩하는 데 사용할 JSON 사진 모델이 필요할 것으로 예상됩니다. 또한 Flickr의 JSON 사진 스트림을 기대합니다. [Photos]뷰 모델의 photos라는 Swift 배열 은 Flickr의 사진 스트림을 나타냅니다. 다시 말하지만, SwiftUI를 사용하여 사용자 인터페이스를 구축하기 위해 이러한 모델과 뷰 모델을 완전히 구현할 필요는 없습니다. 모델과 뷰 모델 코드를 완성하는 것은 필요한 SwiftUI 인터페이스 코드를 작성한 후에 이루어집니다.
와이어 프레임 기반 화면 분해
와이어 프레임 기반 화면 분해 (Jody P. Abney)MainScreen.swift다양한 구성 요소보기를 살펴 보겠습니다 . 기본 화면은 사용자가 사진, 즐겨 찾기 및 설정간에 전환 할 수있는 탭보기 탐색을 사용합니다. 첫 번째 화면 (사진)에 초점을 맞춘 다음은 화면을 구성하는보기의 개요입니다.
- SearchBar.swift — 이보기는 흥미로운 사진, 최근 사진 및 주변 사진과 같이 앱에 대해 계획된 다양한 사진 스트림에서 Flickr 사진을 검색하는 표준 iOS 검색 막대를 생성합니다.
 - PhotoCategoryPicker.swift — 이보기는 사용자가 다양한 사진 스트림 (관심, 최근 및 주변) 중에서 선택할 수 있도록 세그먼트 화 된 컨트롤러보기를 만듭니다.
 - PhotoGrid.swift — 이보기 
LazyZGrid는 선택한 포토 스트림에서 스크롤 가능한 사진을 만듭니다 . - PhotoGridCell.swift — 이보기는 선택한 포토 스트림 내 특정 이미지의 썸네일을 표시합니다. PhotoGridCell을 사용하면 사용자가지도보기 (사진에 지리적 위치 데이터를 사용할 수있는 경우)와 함께 사진의 더 큰보기와 제목, 사진 작가의 화면 이름, 사진 날짜와 같은 사진 세부 정보를 보여주는 PhotoScreen으로 이동할 수 있습니다. 몇 가지 데이터 요소의 이름을 지정하십시오. (다시 말하지만 이러한 데이터 요소는 JSON 사진 모델의 일부일 가능성이 높습니다.)
 
다음은 위의 접근 방식을 사용하여 만든 SwiftUI 화면의 스크린 샷입니다.
스크린 샷 : MainScreen (Jody P. Abney)
의 SwiftUI 코드 MainScreen.swift는 다음과 같습니다.
 | // | 
 | //  MainScreen.swift | 
 | //  MediumArticleFlickrApp | 
 | // | 
 | //  Created by Jody Abney on 12/6/20. | 
 | // | 
 |  | 
 | import SwiftUI | 
 | 
 | 
 | struct MainScreen: View { | 
 |      | 
 |     // Only minimal details of ViewModel are needed to declare | 
 |     // the initial interface using SwiftUI | 
 |     @ObservedObject var viewModel: ViewModel | 
 |      | 
 |     var body: some View { | 
 |         TabView { | 
 |             // Flickr Photos | 
 |             NavigationView { | 
 |                 VStack { | 
 |                     SearchBar(viewModel: viewModel) | 
 |                     PhotoCategoryPicker(viewModel: viewModel) | 
 |                         .padding([.leading, .trailing], 10) | 
 |                      | 
 |                     if viewModel.photos.count == 0 { | 
 |                         Spacer() | 
 |                         EmptySection() | 
 |                         Spacer() | 
 |                     } else { | 
 |                         PhotoGrid(viewModel: viewModel) | 
 |                     } | 
 |                 } | 
 |                 .navigationTitle("Flickr Photos") | 
 | 
 | 
 |             } | 
 |             .tabItem { | 
 |                 VStack { | 
 |                     Image(systemName: "photo.on.rectangle") | 
 |                     Text("Flickr Photos") | 
 |                 } | 
 |             } | 
 |             // Manage Favorites | 
 |             NavigationView { | 
 |                 VStack { | 
 |                     if viewModel.favPhotos.count == 0 { | 
 |                         Spacer() | 
 |                         EmptySection() | 
 |                         Spacer() | 
 |                     } else { | 
 |                         SearchBar(viewModel: viewModel) | 
 |                         FavoritesList(viewModel: viewModel) | 
 |                     } | 
 |                 } | 
 |                 .navigationTitle("Manage Favorites") | 
 |             } | 
 |             .tabItem { | 
 |                 VStack { | 
 |                     Image(systemName: "heart.circle") | 
 |                     Text("Manage Favorites") | 
 |                 } | 
 |             } | 
 |             // Settings | 
 |             NavigationView { | 
 |                 VStack { | 
 |                     SettingsScreen(viewModel: viewModel) | 
 |                 } | 
 |                 .navigationTitle("Settings") | 
 |             } | 
 |             .tabItem { | 
 |                 VStack { | 
 |                     Image(systemName: "gearshape") | 
 |                     Text("Settings") | 
 |                 } | 
 |             } | 
 |         } | 
 |     } | 
 | } | 
 | 
 | 
 | 
 | 
 | 
 | 
 | struct MainScreen_Previews: PreviewProvider { | 
 |     static var previews: some View { | 
 |         Group { | 
 |             MainScreen(viewModel: ViewModel()) | 
 |              | 
 |             Landscape { | 
 |                 MainScreen(viewModel: ViewModel()) | 
 |             } | 
 |         } | 
 |     } | 
 | } | 
다양한보기에 대한 자세한 내용은 아래에 표시되는 코드 목록에서 제공됩니다.
SearchBar.swift SwiftUI 코드 목록 :
 | // | 
 | //  SearchBar.swift | 
 | //  MediumArticleFlickrApp | 
 | // | 
 | //  Created by Jody Abney on 12/29/20. | 
 | // | 
 |  | 
 | import SwiftUI | 
 |   | 
 | struct SearchBar: View { | 
 |     @ObservedObject var viewModel: ViewModel | 
 |   | 
 |     @State private var isEditing = false | 
 |   | 
 |     var body: some View { | 
 |         HStack { | 
 |   | 
 |             TextField("Search ...", text: $viewModel.searchText) | 
 |                 .padding(7) | 
 |                 .padding(.horizontal, 25) | 
 |                 .background(Color(.systemGray6)) | 
 |                 .cornerRadius(8) | 
 |                 .overlay( | 
 |                     HStack { | 
 |                         Image(systemName: "magnifyingglass") | 
 |                             .foregroundColor(.gray) | 
 |                             .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) | 
 |                             .padding(.leading, 8) | 
 |                   | 
 |                         if isEditing { | 
 |                             Button(action: { | 
 |                                 viewModel.searchText = "" | 
 |                             }) { | 
 |                                 Image(systemName: "multiply.circle.fill") | 
 |                                     .foregroundColor(.gray) | 
 |                                     .padding(.trailing, 8) | 
 |                             } | 
 |                         } | 
 |                     } | 
 |                 ) | 
 |                 .padding(.horizontal, 10) | 
 |                 .onTapGesture { | 
 |                     self.isEditing = true | 
 |                 } | 
 |   | 
 |             if isEditing { | 
 |                 Button(action: { | 
 |                     self.isEditing = false | 
 |                     viewModel.searchText = "" | 
 |   | 
 |                 }) { | 
 |                     Text("Cancel") | 
 |                 } | 
 |                 .padding(.trailing, 10) | 
 |                 .transition(.move(edge: .trailing)) | 
 |                 .animation(.default) | 
 |             } | 
 |         } | 
 |     } | 
 | } | 
 | 
 | 
 | struct SearchBar_Previews: PreviewProvider { | 
 |     static var previews: some View { | 
 |         SearchBar(viewModel: ViewModel()) | 
 |             .previewLayout(.sizeThatFits) | 
 |     } | 
 | } | 
의 SwiftUI 코드는 PhotoCategoryPicker.swifta Picker를 활용하여 세그먼트 컨트롤러를 구성하여 사용자가 Flickr 사진 스트림 (관심있는 사진, 최근 사진 또는 주변 사진)을 선택할 수 있도록합니다.
 | // | 
 | //  PhotoCategoryPicker.swift | 
 | //  MediumArticleFlickrApp | 
 | // | 
 | //  Created by Jody Abney on 12/19/20. | 
 | // | 
 |  | 
 | import SwiftUI | 
 | 
 | 
 | struct PhotoCategoryPicker: View { | 
 |     @ObservedObject var viewModel: ViewModel | 
 |      | 
 |     var body: some View { | 
 |         Picker(selection: $viewModel.selectedCategory, | 
 |                label: Text("Photo Category Picker")) { | 
 |             Text("Interesting").tag(PhotoCategory.interestingness) | 
 |             // only show Recent photos option if enabled | 
 |             // via Settings screen | 
 |             if viewModel.includeRecentPhotos { | 
 |                 Text("Recent").tag(PhotoCategory.recent) | 
 |             } | 
 |             Text("Near By").tag(PhotoCategory.nearBy) | 
 |         } | 
 |         .pickerStyle(SegmentedPickerStyle()) | 
 |     } | 
 | } | 
 | 
 | 
 | struct PhotoCategoryPicker_Previews: PreviewProvider {     | 
 |     static var previews: some View { | 
 |         Group { | 
 |             PhotoCategoryPicker(viewModel: ViewModel()) | 
 |                 .previewLayout(.sizeThatFits) | 
 |             PhotoCategoryPicker(viewModel: PreviewViewModel()) | 
 |                 .previewLayout(.sizeThatFits) | 
 |         } | 
 |     } | 
 | } | 
다음 은 선택한 포토 스트림의 이미지를 표시 하기 위해 에서 PhotoGrid.swiftViewModel의 [Photo]배열을 사용하는 방법을 설명 하는 코드 목록입니다 LazyVGrid.
 | // | 
 | //  PhotoGrid.swift | 
 | //  MediumArticleFlickrApp | 
 | // | 
 | //  Created by Jody Abney on 12/6/20. | 
 | // | 
 |  | 
 | import SwiftUI | 
 | 
 | 
 | struct PhotoGrid: View { | 
 |      | 
 |     @ObservedObject var viewModel: ViewModel | 
 |      | 
 |     var columnsAdaptive = [GridItem(.adaptive(minimum: 150, maximum: 300))] | 
 |      | 
 |     var body: some View { | 
 |         ScrollView { | 
 |             LazyVGrid(columns: columnsAdaptive, content: { | 
 |                 ForEach(viewModel.photos) { | 
 |                     photo in | 
 |                     PhotoGridCell(viewModel: viewModel, photo: photo) | 
 |                 } | 
 |             }) | 
 |         } | 
 |         .padding() | 
 |     } | 
 | } | 
 | 
 | 
 | struct PhotoGrid_Previews: PreviewProvider { | 
 |     static var previews: some View {         | 
 |         PhotoGrid(viewModel: ViewModel()) | 
 |     } | 
 | } | 
PhotoGridCell.swift코드 목록은 개별 사진 축소판이 사진 격자 내에 표시되고 다음을위한 탐색 방법으로 활성화되는 방법에 대한 세부 정보를 제공합니다 PhotoScreen.swift.
 | // | 
 | //  PhotoGridCell.swift | 
 | //  MediumArticleFlickrApp | 
 | // | 
 | //  Created by Jody Abney on 12/6/20. | 
 | // | 
 |  | 
 | import KingfisherSwiftUI | 
 | import SwiftUI | 
 | 
 | 
 | struct PhotoGridCell: View { | 
 |      | 
 |     @ObservedObject var viewModel: ViewModel | 
 |      | 
 |     let photo: Photo | 
 |      | 
 |     @State var isActive = false | 
 |      | 
 |     var body: some View { | 
 |         ZStack { | 
 |             // Show a progress view until the photo overlays it | 
 |             ProgressView() | 
 |             // Display the photo | 
 |             CellPhotoView(photo: photo) | 
 |                 // set up for tap navigation | 
 |                 .onTapGesture { | 
 |                     self.isActive.toggle() | 
 |                 } | 
 |                 .background(NavigationLink( | 
 |                                 destination: PhotoScreen(viewModel: viewModel, | 
 |                                                          isFavorite: viewModel.isFavorite(photo: photo), | 
 |                                                          photo: photo), | 
 |                                 isActive: $isActive) { EmptyView() } | 
 |                 ) | 
 |         } | 
 |         .padding() | 
 |     } | 
 | } | 
 | 
 | 
 | struct PhotoGridCell_Previews: PreviewProvider { | 
 |     static var previews: some View { | 
 |         PhotoGridCell(viewModel: ViewModel(), photo: Photo.default) | 
 |     } | 
 | } | 
CellPhotoView.swift코드 목록은 KingfisherSwiftUIURL에서 사진 데이터를 디코딩하는 수단으로 Swift Package 를 활용하는 동시에 불필요한 데이터 전송을 방지하기 위해 이미지 캐싱을 활성화합니다.
 | // | 
 | //  CellPhotoView.swift | 
 | //  MediumArticleFlickrApp | 
 | // | 
 | //  Created by Jody Abney on 12/21/20. | 
 | // | 
 |  | 
 | import KingfisherSwiftUI | 
 | import SwiftUI | 
 | 
 | 
 | struct CellPhotoView: View { | 
 |      | 
 |     let photo: Photo | 
 |      | 
 |     var body: some View { | 
 |         // Display the photo | 
 |         KFImage(photo.remoteURL) | 
 |             // set photo display characteristics | 
 |             .resizable() | 
 |             .aspectRatio(contentMode: .fit) | 
 |             .cornerRadius(10.0) | 
 |     } | 
 | } | 
 | 
 | 
 | struct CellPhotoView_Previews: PreviewProvider { | 
 |     static var previews: some View { | 
 |         CellPhotoView(photo: Photo.default) | 
 |             .previewLayout(.sizeThatFits) | 
 |     } | 
 | } | 
 | 
 | 
 | 
 | 
PhotoScreen (사진 상세 화면)
다음은 사용자가 사진 격자에서 이미지를 탭하면 사진과 해당 세부 정보가 어떻게 표시되는지 보여주는 PhotoScreen의 스크린 샷입니다.
스크린 샷 : Compact Horizontal Class Size (Jody P. Abney) 기반 PhotoScreen다음에 대한 SwiftUI 코드 목록은 다음과 같습니다 PhotoScreen.swift.
 | // | 
 | //  PhotoScreen.swift | 
 | //  MediumArticleFlickrApp | 
 | // | 
 | //  Created by Jody Abney on 12/20/20. | 
 | // | 
 |  | 
 | import MapKit | 
 | import SwiftUI | 
 | 
 | 
 | struct PhotoScreen: View { | 
 |     // Handle device sizes to allow the views to change based on horizontal class size | 
 |     @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? | 
 |      | 
 |     @ObservedObject var viewModel: ViewModel | 
 |      | 
 |     @State var isFavorite: Bool | 
 |      | 
 |     let licenses = Licenses() | 
 |     var photo: Photo | 
 |      | 
 |     // photo content mode | 
 |     @State var contentMode = ContentMode.fit | 
 |      | 
 |     // Computed property for offset in overlaps | 
 |     var offsetValue: CGFloat { | 
 |         horizontalSizeClass == UserInterfaceSizeClass.compact ? -130 : 0 | 
 |     } | 
 |      | 
 |     var body: some View { | 
 |         if horizontalSizeClass == UserInterfaceSizeClass.compact { | 
 |             VStack { | 
 |                 PhotoScreenView(contentMode: $contentMode, | 
 |                                 photo: photo, | 
 |                                 offsetValue: offsetValue) | 
 |                  | 
 |                 if contentMode == .fit { | 
 |                     // Set up photo details view | 
 |                     PhotoDetails(photo: photo) | 
 |                      | 
 |                     Spacer() | 
 |                      | 
 |                     // Set up photo license view | 
 |                     PhotoLicenseView(license: licenses.getPhotoLicense(id: photo.license)) | 
 |                 } | 
 |             } | 
 |             .navigationBarTitle(Text(photo.title)) | 
 |             .navigationBarItems(trailing: viewModel.authenticated ? | 
 |                                     Button(action: { | 
 |                                         if !isFavorite { | 
 |                                             viewModel.favPhotos.append(photo) | 
 |                                         } else { | 
 |                                             let _ = viewModel.favPhotos.removeAll { (vmPhoto) -> Bool in | 
 |                                                 vmPhoto.id == photo.id | 
 |                                             } | 
 |                                         } | 
 |                                         isFavorite.toggle() | 
 |                                          | 
 |                                         // TODO: Implement actual add/remove fav functionality with ViewModel | 
 |                                          | 
 |                                     } ) { | 
 |                                         Image(systemName: isFavorite ? "heart.fill" : "heart") | 
 |                                     } : nil ) | 
 |              | 
 |         } else { | 
 |             HStack { | 
 |                 PhotoScreenView(contentMode: $contentMode, | 
 |                                 photo: photo, | 
 |                                 offsetValue: offsetValue) | 
 |                  | 
 |                 if contentMode == .fit { | 
 |                     VStack { | 
 |                         // Set up photo details view | 
 |                         PhotoDetails(photo: photo) | 
 |                          | 
 |                         Spacer() | 
 |                          | 
 |                         // Set up photo license view | 
 |                         PhotoLicenseView(license: licenses.getPhotoLicense(id: photo.license)) | 
 |                     } | 
 |                     .padding() | 
 |                 } | 
 |             } | 
 |             .navigationBarTitle(Text(photo.title)) | 
 |             .navigationBarItems(trailing: viewModel.authenticated ? | 
 |                                     Button(action: { | 
 |                                         if !isFavorite { | 
 |                                             viewModel.favPhotos.append(photo) | 
 |                                         } else { | 
 |                                             let _ = viewModel.favPhotos.drop { (vmPhoto) -> Bool in | 
 |                                                 vmPhoto.id == photo.id | 
 |                                             } | 
 |                                         } | 
 |                                         isFavorite.toggle() | 
 |                                          | 
 |                                         // TODO: Implement actual add/remove fav functionality with ViewModel | 
 |                                          | 
 |                                     } ) { | 
 |                                         Image(systemName: isFavorite ? "heart.fill" : "heart") | 
 |                                     } : nil ) | 
 |         } | 
 |     } | 
 | } | 
 | 
 | 
 | 
 | 
 | struct PhotoScreen_Previews: PreviewProvider { | 
 |     static var previews: some View { | 
 |         Group { | 
 |             PhotoScreen(viewModel: ViewModel(), isFavorite: false, photo: Photo.default) | 
 |                 .environment(\.horizontalSizeClass, UserInterfaceSizeClass.compact) | 
 |              | 
 |             PhotoScreen(viewModel: ViewModel(), isFavorite: true, photo: Photo.default, contentMode: .fill) | 
 |                 .environment(\.horizontalSizeClass, UserInterfaceSizeClass.compact) | 
 |              | 
 |             Landscape { | 
 |                 PhotoScreen(viewModel: ViewModel(), isFavorite: false, photo: Photo.default, contentMode: .fit) | 
 |                     .previewDevice("iPhone 12 Pro Max") | 
 |                     .environment(\.horizontalSizeClass, UserInterfaceSizeClass.regular) | 
 |             } | 
 |              | 
 |             Landscape { | 
 |                 PhotoScreen(viewModel: ViewModel(), isFavorite: true, photo: Photo.default, contentMode: .fill) | 
 |                     .previewDevice("iPhone 12 Pro Max") | 
 |                     .environment(\.horizontalSizeClass, UserInterfaceSizeClass.regular) | 
 |             } | 
 |         } | 
 |     } | 
 | } | 
 | 
 | 
사진 화면 레이아웃은 장치의 수평 클래스 크기에 따라 조정됩니다. 크기가 .compact이면 세로보기가 표시됩니다. 기기의 수평 클래스 크기가 .regular이면 사용 가능한 화면 공간을 더 잘 활용하기 위해 수평 레이아웃이 사용됩니다.
스크린 샷 : 정규 수평 클래스 크기를 기반으로 한 PhotoScreen (Jody P. Abney)PhotoScreen.swift파일 내의 각 뷰 구성 요소에 대한 추가 개별 코드 목록을 게시하는 대신 이 기사 끝에서 사용할 수있는 전체 GitHub 저장소를 독자에게 참조 할 것입니다.
즐겨 찾기 화면 관리
스크린 샷 : 즐겨 찾기 관리 화면 (Jody P. Abney)설정 화면
스크린 샷 : 설정 화면 (Jody P. Abney)마무리
이 기사가 SwiftUI에 대한 귀하의 욕구를 불러 일으키고 Apple의 선언적 인터페이스 언어를 사용하여 앱을 개발해 보도록 영감을주기를 바랍니다. 내 SwiftUI 코드에 대한 더 자세한 정보를 얻으려면 내 GitHub.com 저장소를 탐색하는 것이 좋습니다.
무엇 향후 계획?
다음 기사에서는 JSON 데이터 모델링 및 네트워킹에 접근하는 방법을 설명합니다.
다음 시간까지 안전을 유지하고 계속 학습하세요!
전체 저장소는 아래 링크에서 사용할 수 있으며 독자는 앱에 포함 된 각 화면 및보기에 대한 자세한 내용을 탐색 할 수 있습니다 .
 
댓글 없음:
댓글 쓰기