 Unsplash에 Tirza van Dijk의 사진
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)
와이어 프레임 기반 화면 분해 (Jody P. Abney)MainScreen.swift다양한 구성 요소보기를 살펴 보겠습니다 . 기본 화면은 사용자가 사진, 즐겨 찾기 및 설정간에 전환 할 수있는 탭보기 탐색을 사용합니다. 첫 번째 화면 (사진)에 초점을 맞춘 다음은 화면을 구성하는보기의 개요입니다.
- SearchBar.swift — 이보기는 흥미로운 사진, 최근 사진 및 주변 사진과 같이 앱에 대해 계획된 다양한 사진 스트림에서 Flickr 사진을 검색하는 표준 iOS 검색 막대를 생성합니다.
- PhotoCategoryPicker.swift — 이보기는 사용자가 다양한 사진 스트림 (관심, 최근 및 주변) 중에서 선택할 수 있도록 세그먼트 화 된 컨트롤러보기를 만듭니다.
- PhotoGrid.swift — 이보기 LazyZGrid는 선택한 포토 스트림에서 스크롤 가능한 사진을 만듭니다 .
- PhotoGridCell.swift — 이보기는 선택한 포토 스트림 내 특정 이미지의 썸네일을 표시합니다. PhotoGridCell을 사용하면 사용자가지도보기 (사진에 지리적 위치 데이터를 사용할 수있는 경우)와 함께 사진의 더 큰보기와 제목, 사진 작가의 화면 이름, 사진 날짜와 같은 사진 세부 정보를 보여주는 PhotoScreen으로 이동할 수 있습니다. 몇 가지 데이터 요소의 이름을 지정하십시오. (다시 말하지만 이러한 데이터 요소는 JSON 사진 모델의 일부일 가능성이 높습니다.)
다음은 위의 접근 방식을 사용하여 만든 SwiftUI 화면의 스크린 샷입니다.
 스크린 샷 : MainScreen (Jody P. Abney)
스크린 샷 : 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
스크린 샷 : 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 (Jody P. Abney)PhotoScreen.swift파일 내의 각 뷰 구성 요소에 대한 추가 개별 코드 목록을 게시하는 대신 이 기사 끝에서 사용할 수있는 전체 GitHub 저장소를 독자에게 참조 할 것입니다.
즐겨 찾기 화면 관리
 스크린 샷 : 즐겨 찾기 관리 화면 (Jody P. Abney)
스크린 샷 : 즐겨 찾기 관리 화면 (Jody P. Abney)설정 화면
 스크린 샷 : 설정 화면 (Jody P. Abney)
스크린 샷 : 설정 화면 (Jody P. Abney)마무리
이 기사가 SwiftUI에 대한 귀하의 욕구를 불러 일으키고 Apple의 선언적 인터페이스 언어를 사용하여 앱을 개발해 보도록 영감을주기를 바랍니다. 내 SwiftUI 코드에 대한 더 자세한 정보를 얻으려면 내 GitHub.com 저장소를 탐색하는 것이 좋습니다.
무엇 향후 계획?
다음 기사에서는 JSON 데이터 모델링 및 네트워킹에 접근하는 방법을 설명합니다.
다음 시간까지 안전을 유지하고 계속 학습하세요!
전체 저장소는 아래 링크에서 사용할 수 있으며 독자는 앱에 포함 된 각 화면 및보기에 대한 자세한 내용을 탐색 할 수 있습니다 .
 
댓글 없음:
댓글 쓰기