跳转至

组合复杂界面

Landmarks 的主屏显示了一个滚动的分类列表,每个分类中都有水平滚动的地标标记。通过构建这样的主导航,我们来探究组合视图是怎样适配不同设备大小和方向的。

下载项目文件并按照以下步骤操作,也可以打开已完成的项目自行浏览代码。

  • 预计完成时间:20 分钟
  • 项目文件:下载

1. 添加主视图

现在我们已经做好了 Landmarks app 所需的所有视图,是时候给它们一个统一的主视图了。 主视图不仅包含了所有其他视图,还提供了浏览和显示地标的方法。

1.1 在一个新文件 Home.swift 中创建一个自定义视图 CategoryHome

Home.swift

import SwiftUI

struct CategoryHome: View {
    var body: some View {
        Text("Landmarks Content")
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

1.2 修改 SceneDelegate ,把显示的地标列表换成 CategoryHome 视图。

SceneDelegate.swift

import SwiftUI
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Use a UIHostingController as window root view controller
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(
                //
                rootView: CategoryHome()
                //
                    .environmentObject(UserData())
            )
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}

现在主视图成了 Landmarks app 的根,所以它需要一个方式去显示其他视图。

1.3 在 Landmarks 中添加一个 NavigationView 来组织别的视图。

我们在 app 中使用 NavigationViewNavigationButton 实例以及其他相关方法来构建分层导航结构。

Home.swift

import SwiftUI

struct CategoryHome: View {
    var body: some View {
        //
        NavigationView {
            Text("Landmarks Content")
        }
        //
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

1.4 把导航栏设置成 Featured

Home.swift

import SwiftUI

struct CategoryHome: View {
    var body: some View {
        NavigationView {
            Text("Landmarks Content")
                //
                .navigationBarTitle(Text("Featured"))
                //
        }
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

2. 创建一个分类列表

Landmarks app 以垂直的独立行视图显示所有分类,这给浏览提供了便利。我们可以通过组合垂直和水平 stack ,并给列表添加滚动来完成此需求。

2.1 使用 Dictionary 结构的初始化方法 init(grouping:by:) 把地标组合到分类中,输入地标的 category 属性。

初始化项目文件给每个地标包含了预设的分类。

Home.swift

import SwiftUI

struct CategoryHome: View {
    //
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }
    //

    var body: some View {
        NavigationView {
            Text("Landmarks Content")
                .navigationBarTitle(Text("Featured"))
        }
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

2.2 在 Landmarks 中使用 List 来显示分类。

Landmark.Category 会匹配列表中每一项的 name ,这些项目在其他分类中必须是唯一的,因为它是枚举。

Home.swift

import SwiftUI

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }

    var body: some View {
        NavigationView {
            //
            List {
                ForEach(categories.keys.sorted(), id: \.self) { key in
                    Text(key)
                }
            }
            .navigationBarTitle(Text("Featured"))
            //
        }
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

3. 给 Landmarks 添加行视图

Landmarks 在一个水平滚动的行视图上显示每个分类。添加一个新的视图类型来表示行视图,然后在这个新视图中显示该分类所有的地标。

3.1 定义一个新的自定义视图来保存行视图的内容。

这个视图需要保存显示特定地标分类的信息以及对应的地标。

CategoryRow.swift

import SwiftUI

struct CategoryRow: View {
    //
    var categoryName: String
    var items: [Landmark]

    var body: some View {
        Text(self.categoryName)
            .font(.headline)
    }
    //
}

struct CategoryRow_Previews: PreviewProvider {
    static var previews: some View {
        //
        CategoryRow(
            categoryName: landmarkData[0].category.rawValue,
            items: Array(landmarkData.prefix(3))
        )
        //
    }
}

更新 CategoryRowbody ,给新的行视图类型传入分类信息。

CategoryRow.swift

import SwiftUI

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }

    var body: some View {
        NavigationView {
            List {
                ForEach(categories.keys.sorted(), id: \.self) { key in
                    //
                    CategoryRow(categoryName: key, items: self.categories[key]!)
                    //
                }
            }
            .navigationBarTitle(Text("Featured"))
        }
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

3.3 在一个 HStack 中显示分类中的地标。

CategoryRow.swift

import SwiftUI

struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]

    var body: some View {
        HStack(alignment: .top, spacing: 0) {
            ForEach(self.items) { landmark in
                Text(landmark.name)
            }
        }
    }
}

struct CategoryRow_Previews: PreviewProvider {
    static var previews: some View {
        CategoryRow(
            categoryName: landmarkData[0].category.rawValue,
            items: Array(landmarkData.prefix(3))
        )
    }
}

3.4 调用 frame(width:height:) 让行视图的空间大一些,然后把 stack 包装在一个 ScrollView 中。

使用很长的数据样本更新预览来确保可以正确滚动。

CategoryRow.swift

import SwiftUI

struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]

    var body: some View {
        //
        VStack(alignment: .leading) {
            Text(self.categoryName)
                .font(.headline)
                .padding(.leading, 15)
                .padding(.top, 5)

            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .top, spacing: 0) {
                    ForEach(self.items) { landmark in
                        Text(landmark.name)
                    }
                }
            }
            .frame(height: 185)
        }
        //
    }
}

struct CategoryRow_Previews: PreviewProvider {
    static var previews: some View {
        CategoryRow(
            categoryName: landmarkData[0].category.rawValue,
            items: Array(landmarkData.prefix(4))
        )
    }
}

4. 组合主视图

在用户点击一个地标去了解详情之前, Landmarks app 的主视图需要显示地标的简易信息。

重新使用我们在 创建和组合 view 中的视图来创建类似但更简单的视图预览,它们用来显示地标分类和特征。

4.1 在 CategoryRow 下面创建一个自定义视图 CategoryItem ,然后用新视图替换包含地标名称的 Text

CategoryRow.swift

import SwiftUI

struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]

    var body: some View {
        VStack(alignment: .leading) {
            Text(self.categoryName)
                .font(.headline)
                .padding(.leading, 15)
                .padding(.top, 5)

            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .top, spacing: 0) {
                    ForEach(self.items) { landmark in
                        //
                        CategoryItem(landmark: landmark)
                        //
                    }
                }
            }
            .frame(height: 185)
        }
    }
}

//
struct CategoryItem: View {
    var landmark: Landmark
    var body: some View {
        VStack(alignment: .leading) {
            landmark.image
                .resizable()
                .frame(width: 155, height: 155)
                .cornerRadius(5)
            Text(landmark.name)
                .font(.caption)
        }
        .padding(.leading, 15)
    }
}
//

struct CategoryRow_Previews: PreviewProvider {
    static var previews: some View {
        CategoryRow(
            categoryName: landmarkData[0].category.rawValue,
            items: Array(landmarkData.prefix(4))
        )
    }
}

4.2 在 Home.swift 中添加一个简易视图 FeaturedLandmarks ,用来显示只有被标记了 isFeatured 的地标。

我们会在稍后的教程中把这个视图转换成一个可交互的轮播。目前,它显示一个缩放并裁剪后的地标特征图片。

Home.swift

import SwiftUI

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }

    //
    var featured: [Landmark] {
        landmarkData.filter { $0.isFeatured }
    }
    //

    var body: some View {
        NavigationView {
            List {
                //
                FeaturedLandmarks(landmarks: featured)
                    .scaledToFill()
                    .frame(height: 200)
                    .clipped()
                //
                ForEach(categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: self.categories[key]!)
                }
            }
            .navigationBarTitle(Text("Featured"))
        }
    }
}

//
struct FeaturedLandmarks: View {
    var landmarks: [Landmark]
    var body: some View {
        landmarks[0].image.resizable()
    }
}
//

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

4.3 把地标预览两边的 edge insets 都设置成 zero ,这样内容就可以展开到显示的边缘。

Home.swift

import SwiftUI

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }

    var featured: [Landmark] {
        landmarkData.filter { $0.isFeatured }
    }

    var body: some View {
        NavigationView {
            List {
                FeaturedLandmarks(landmarks: featured)
                    .scaledToFill()
                    .frame(height: 200)
                    .clipped()
                    //
                    .listRowInsets(EdgeInsets())
                    //

                ForEach(categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: self.categories[key]!)
                }
                //
                .listRowInsets(EdgeInsets())
                //
            }
            .navigationBarTitle(Text("Featured"))
        }
    }
}

struct FeaturedLandmarks: View {
    var landmarks: [Landmark]
    var body: some View {
        landmarks[0].image.resizable()
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

5. 在 Sections 之间添加导航

现在,在主视图中可以看到所有不同分类的地标,用户需要一种方法来访问 app 中的每个部分。使用 navigationpresentation API 可以从主视图导航到详情视图,收藏列表和用户简介 。

5.1 在 CategoryRow.swift 中,把现有的 CategoryItem 包装在一个 NavigationButton 中。

分类项本身是按钮的 label ,它的目标是卡片中显示地标的详情视图。

CategoryRow.swift

import SwiftUI

struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]

    var body: some View {
        VStack(alignment: .leading) {
            Text(self.categoryName)
                .font(.headline)
                .padding(.leading, 15)
                .padding(.top, 5)

            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .top, spacing: 0) {
                    ForEach(self.items) { landmark in
                        //
                        NavigationLink(
                            destination: LandmarkDetail(
                                landmark: landmark
                            )
                        ) {
                            CategoryItem(landmark: landmark)
                        }
                        //
                    }
                }
            }
            .frame(height: 185)
        }
    }
}

struct CategoryItem: View {
    var landmark: Landmark
    var body: some View {
        VStack(alignment: .leading) {
            landmark.image
                .resizable()
                .frame(width: 155, height: 155)
                .cornerRadius(5)
            Text(landmark.name)
                .font(.caption)
        }
        .padding(.leading, 15)
    }
}

struct CategoryRow_Previews: PreviewProvider {
    static var previews: some View {
        CategoryRow(
            categoryName: landmarkData[0].category.rawValue,
            items: Array(landmarkData.prefix(4))
        )
    }
}

注意:在 Xcode 11 beta 6 中,如果你在一个 List 中嵌套了 ScrollView ,并且这个 ScrollView 包含一个 NavigationLink ,那么当用户点击它时,这个链接并不会导航到目标视图。

5.2 通过应用 renderingMode(_:)color(_:) 修饰符改变分类项的导航外观。

我们给作为 navigation buttonlabel 传递的文字会使用环境的强调色渲染,并且图像可能会被当做 template image。我们可以修改任何一种行为来满足设计。

CategoryRow.swift

import SwiftUI

struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]

    var body: some View {
        VStack(alignment: .leading) {
            Text(self.categoryName)
                .font(.headline)
                .padding(.leading, 15)
                .padding(.top, 5)

            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .top, spacing: 0) {
                    ForEach(self.items) { landmark in
                        NavigationLink(
                            destination: LandmarkDetail(
                                landmark: landmark
                            )
                        ) {
                            CategoryItem(landmark: landmark)
                        }
                    }
                }
            }
            .frame(height: 185)
        }
    }
}

struct CategoryItem: View {
    var landmark: Landmark
    var body: some View {
        VStack(alignment: .leading) {
            landmark.image
                //
                .renderingMode(.original)
                //
                .resizable()
                .frame(width: 155, height: 155)
                .cornerRadius(5)
            Text(landmark.name)
                //
                .foregroundColor(.primary)
                //
                .font(.caption)
        }
        .padding(.leading, 15)
    }
}

struct CategoryRow_Previews: PreviewProvider {
    static var previews: some View {
        CategoryRow(
            categoryName: landmarkData[0].category.rawValue,
            items: Array(landmarkData.prefix(4))
        )
    }
}

5.3 在 Home.swift 中,在标签栏中点击简介图标,添加一个模态视图来显示用户的简介。

showProfile 状态变量设置为 true 时,SwiftUI 将显示用户简介占位符。当用户关闭模态后,将 showProfile设置回 false

Home.swift

import SwiftUI

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }

    var featured: [Landmark] {
        landmarkData.filter { $0.isFeatured }
    }

    //
    @State var showingProfile = false
    //

    var body: some View {
        NavigationView {
            List {
                FeaturedLandmarks(landmarks: featured)
                    .scaledToFill()
                    .frame(height: 200)
                    .clipped()
                    .listRowInsets(EdgeInsets())

                ForEach(categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: self.categories[key]!)
                }
                .listRowInsets(EdgeInsets())
            }
            .navigationBarTitle(Text("Featured"))
            //
            .sheet(isPresented: $showingProfile) {
                Text("User Profile")
            }
            //
        }
    }
}

struct FeaturedLandmarks: View {
    var landmarks: [Landmark]
    var body: some View {
        landmarks[0].image.resizable()
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

5.4 在导航栏上添加一个按钮,当点击后将 showProfilefalse 切换到 true

Home.swift

import SwiftUI

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        //
        .init(
        //
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }

    var featured: [Landmark] {
        landmarkData.filter { $0.isFeatured }
    }

    @State var showingProfile = false

    //
    var profileButton: some View {
        Button(action: { self.showingProfile.toggle() }) {
            Image(systemName: "person.crop.circle")
                .imageScale(.large)
                .accessibility(label: Text("User Profile"))
                .padding()
        }
    }
    //

    var body: some View {
        NavigationView {
            List {
                FeaturedLandmarks(landmarks: featured)
                    .scaledToFill()
                    .frame(height: 200)
                    .clipped()
                    .listRowInsets(EdgeInsets())

                ForEach(categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: self.categories[key]!)
                }
                .listRowInsets(EdgeInsets())
            }
            .navigationBarTitle(Text("Featured"))
            //
            .navigationBarItems(trailing: profileButton)
            //
            .sheet(isPresented: $showingProfile) {
                Text("User Profile")
            }
        }
    }
}

struct FeaturedLandmarks: View {
    var landmarks: [Landmark]
    var body: some View {
        //
        landmarks[0].image(forSize: 250).resizable()
        //
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

5.5 添加一个导航链接,指向可以过滤所有地标的列表,这样主屏幕就完成了。

Home.swift

import SwiftUI

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        //
        Dictionary(
        //
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }

    var featured: [Landmark] {
        landmarkData.filter { $0.isFeatured }
    }

    @State var showingProfile = false

    var profileButton: some View {
        Button(action: { self.showingProfile.toggle() }) {
            Image(systemName: "person.crop.circle")
                .imageScale(.large)
                .accessibility(label: Text("User Profile"))
                .padding()
        }
    }

    var body: some View {
        NavigationView {
            List {
                FeaturedLandmarks(landmarks: featured)
                    .scaledToFill()
                    .frame(height: 200)
                    .clipped()
                    .listRowInsets(EdgeInsets())

                ForEach(categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: self.categories[key]!)
                }
                .listRowInsets(EdgeInsets())

                //
                NavigationLink(destination: LandmarkList()) {
                    Text("See All")
                }
                //
            }
            .navigationBarTitle(Text("Featured"))
            .navigationBarItems(trailing: profileButton)
            .sheet(isPresented: $showingProfile) {
                Text("User Profile")
            }
        }
    }
}

struct FeaturedLandmarks: View {
    var landmarks: [Landmark]
    var body: some View {
        //
        landmarks[0].image.resizable()
        //
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

5.6 在 LandmarkList.swift 中,移除包装地标列表的 NavigationView ,并把它添加到预览中。

中 app 的环境中, LandmarkList 将始终显示在 Home.swift 中声明的导航视图中。

LandmarkList.swift

import SwiftUI

struct LandmarkList: View {
    @EnvironmentObject var userData: UserData

    var body: some View {
        //
        //
        List {
            Toggle(isOn: $userData.showFavoritesOnly) {
                Text("Favorites only")
            }

            ForEach(userData.landmarks) { landmark in
                if !self.userData.showFavoritesOnly || landmark.isFavorite {
                    NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
        }
        .navigationBarTitle(Text("Landmarks"))
        //
        //
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        //
        NavigationView {
            LandmarkList()
                .environmentObject(UserData())
        }
        //
    }
}