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.swift
a 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.swift
ViewModel의 [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
코드 목록은 KingfisherSwiftUI
URL에서 사진 데이터를 디코딩하는 수단으로 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 데이터 모델링 및 네트워킹에 접근하는 방법을 설명합니다.
다음 시간까지 안전을 유지하고 계속 학습하세요!
전체 저장소는 아래 링크에서 사용할 수 있으며 독자는 앱에 포함 된 각 화면 및보기에 대한 자세한 내용을 탐색 할 수 있습니다 .
댓글 없음:
댓글 쓰기