NavigationStack

从 iOS16 开始, 已经用 NavigationStack 取代了 NavigationViewNavigationStack 除了在视图顶部显示导航栏外,还可以将视图推送到导航堆栈上。例如想实现点击 NavigationStack 中的元素时,展示一个新视图,就要用到 NavigationLink

NavigationStack 在使用时会填满整个屏幕,属于扩张视图,不必设置它的大小


设置导航栏标题

使用 navigationTitle() 修饰符可以在导航栏中显示字符串;顶部导航栏默认情况下是不可见的,但只要向上滚动,就会变成纯灰色背景,以便其标题从列表的内容中清晰地突出。

NavigationStack {
		List { ... }
			.navigationTitle("Restaurants")
}

还可以让导航栏标题变成可编辑。但只有当导航栏处于 .inline 显示模式时,才能启用该特性:

// 如果使用 .inline 标题显示模式,我们还可以将一个【字符串绑定值】传递给 navigationTitle() 
// 这样标题可以根据用户输入的内容进行修改,这非常有用,因为这意味着您不需要在布局中添加额外的文本字段
// 使用【绑定值】只有一个影响:iOS 将在标题旁边显示一个小箭头,显示一个“重命名”按钮来更改标题
struct ContentView: View {
    @State private var title = "SwiftUI"
    var body: some View {
        NavigationStack {
            Text("Hello, world!")
            .navigationTitle($title)
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

设置导航栏样式

NavigationStack 的大部份修饰符,都是添加到 NavigationStack 大括号里面的(不是外面)。人们很容易误认为许多导航修饰符应该附加到 的 NavigationStack 末尾,但实际上它是附加到【内部视图的末尾】。原因是导航堆栈能够在程序运行时显示许多视图,因此通过将标题附加到导航堆栈中的事物,可以允许 iOS 自由更改标题。

NavigationStack {

	List { ... }
	
	// 设置导航栏标题样式:.inline .large
	.navigationBarTitleDisplayMode(.inline)

	// 隐藏默认的返回按钮
	.navigationBarBackButtonHidden(true)
	
	
	//提示:以后还会遇到其他类型的 toolbar ,上面两个修饰符会影响所有的 toolbar。
	//如果只想修改导航栏的 toolbar 样式,则可以将 for: .navigationBar 作为第2个参数添加到以上两个 toolbar 中
	
	// 设置导航栏背景颜色
	.toolbarBackground(.blue)

	// 强制导航栏始终使用深色模式
	.toolbarColorScheme(.dark)
	
	// 强制隐藏 toolbar (隐藏 toolbar 不会影响导航到新视图,但可能会导致滚动视图位于时钟等系统信息下)
	// 因为 toolbar 可以指【导航栏、底部栏、键盘上方以及模态视图等工具栏…】,所以第二个参数要明确是哪个
	.toolbar(.hidden, for: .navigationBar)

}

只有这个修饰符是加到 NavigationStack 大括号外面的:

NavigationStack { 
		
		// 子视图的可以覆盖
		.tint(.red)
		
}
// 它可以一次设置整个 NavigationStack 内的所有链接按钮的激活颜色
// 但如果某个子视图的链接按钮希望用别的颜色,可以在子视图下也添加该修饰符,覆盖这个父视图的修饰符
.tint(.black)

自定义导航栏样式

更高自由度地定制标题栏样式,需要借助 UIKit 所提供的 UINavigationBarAppearance API来实现,方法如下:

// iOS13起,加入了一个`UINavigationBarAppearance`的API,可以自定义标题栏:
// - 標準外觀 (.standardAppearance) - 標準高度的導覽列外觀 (iPhone 直立型式時出現的導覽列 )
// - 窄化外觀 (.compactAppearance) - 窄化導覽列的外觀 (例如,iPhone 橫向型式所顯示的導覽列 )
// - 滾動邊緣外觀 (.scrollEdgeAppearance) - 這是當滾動內容邊緣滾到導覽列時的外觀

//加到页面的View里面,body的上面
init(){
        let navBarAppearance = UINavigationBarAppearance()

				//largeTitleTextAttributes 设定大尺寸标题的文字
        navBarAppearance.largeTitleTextAttributes = [
            .foregroundColor:UIColor.systemRed,
            .font: UIFont(name: "ArialRoundedMTBold", size: 35)!
        ]

				//titleTextAttributes 设定标准尺寸标题的文字
        navBarAppearance.titleTextAttributes = [
            .foregroundColor:UIColor.systemRed,
            .font: UIFont(name: "ArialRoundedMTBold", size: 20)!
        ]
        
				//设定完 navBarAppearance 后,将它指定给三个外观属性。
        UINavigationBar.appearance().standardAppearance = navBarAppearance
        UINavigationBar.appearance().compactAppearance = navBarAppearance
				UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance

}

searchable 搜索栏

iOS 可以使用 searchable() 修饰符将搜索栏添加到 NavigationStack 中,并且可以将字符串属性绑定到它以在用户输入时过滤数据。该搜索栏要么在简单布局中保持固定,要么在与列表一起使用时自动显示和滚动。为了获得更多功能,您还可以使用 searchScopes() 来控制搜索发生的位置。

记得需要确保视图位于 NavigationStack 内,否则 iOS 将没有地方放置搜索框。

基本用法

最简单的形式,只需将 searchable() 添加到导航堆栈内的某个视图后面,并且绑定一个状态属性,即可添加搜索框

struct ContentView: View {
    @State private var searchText = ""
    var body: some View {
        NavigationStack {
            Text("Searching for \\(searchText)")
                .navigationTitle("Searchable Example")
        }
        .searchable(text: $searchText)            
    }
}

// 将 searchable() 附加到 NavigationStack 或 NavigationSplitView ,将留给系统来决定最适合显示搜索框的位置。
// 如果您特别希望将其附加到一个视图,您可以根据需要移动它,或尝试其 placement 参数。

参数:prompt

使用参数 prompt ,可以提供一个字符串作为搜索框的提示

.searchable(text: $searchText, prompt: "Look for something")

参数:isPresented

如果想跟踪搜索框当前是否处于活动状态,在 iOS 17 中,可以使用参数 isPresented 绑定布尔值,如下所示:

struct ContentView: View {
    @State private var searchText = ""
    @State private var searchIsActive = false
    var body: some View {
        NavigationStack {
            Text("Searching for \\(searchText)")
                .navigationTitle("Searching: \\(searchIsActive ? "Yes" : "No")")
        }
        .searchable(text: $searchText, isPresented: $searchIsActive)
    }
}

<aside> 💡 由于搜索栏现在出现在列表内,因此它通常会开始隐藏 - 用户需要在顶部轻轻向下拖动列表才能显示它。

</aside>

参数:Tokens & suggestedTokens

searchable() 还可以允许用户直接选择搜索标记,现这个需要以下几个步骤:

还有一个额外的问题: searchable() 的 iOS implementation 自动补全,会使用建议的 tokens 替换您的搜索结果,这使得默认搜索的用处大大降低。因此,我更喜欢要求用户以“#”符号开头来激活令牌过滤,类似于 Twitter。以下是完整的例子:

// 带唯一标识符的 movie 数据类型
struct Movie: Identifiable {
    var id = UUID()
    var name: String
    var genre: String
}

// 希望用户用于过滤的 token 类型,它必须遵循 Identifiable 协议
struct Token: Identifiable {
    var id: String { name }
    var name: String
}

struct ContentView: View {
    // 状态属性:用户当前输入的搜索词
    @State private var searchText = ""

    // 想向用户展示的所有可能的 Token
    let allTokens = [
		    Token(name: "Action"), 
		    Token(name: "Comedy"), 
		    Token(name: "Drama"), 
		    Token(name: "Family"), 
		    Token(name: "Sci-Fi")
		]

    // 用户当前选择的搜索 Token 列表
    @State private var currentTokens = [Token]()

    // 当前想要向用户展示的 Tokens 列表,仅当用户输入的搜索词以 # 号开头时激活
    var suggestedTokens: [Token] {
        if searchText.starts(with: "#") {
            return allTokens
        } else {
            return []
        }
    }

    // 模拟数据
    let movies = [
        Movie(name: "Avatar", genre: "Sci-Fi"),
        Movie(name: "Inception", genre: "Sci-Fi"),
        Movie(name: "Love Actually", genre: "Comedy"),
        Movie(name: "Paddington", genre: "Family")
    ]

    // 真正的任务:根据搜索词 或 搜索 tokens 过滤 movie 数据
    var searchResults: [Movie] {
        // 去除空格
        let trimmedSearchText = searchText.trimmingCharacters(in: .whitespaces)

        return movies.filter { movie in
            if searchText.isEmpty == false {
                // If we have search text, make sure this item matches.
                if movie.name.localizedCaseInsensitiveContains(trimmedSearchText) == false {
                    return false
                }
            }

            if currentTokens.isEmpty == false {
                // If we have search tokens, loop through them all to make sure one of them matches our movie.
                for token in currentTokens {
                    if token.name.localizedCaseInsensitiveContains(movie.genre) {
                        return true
                    }
                }

                // This movie does *not* match any of our tokens, so it shouldn't be sent back.
                return false
            }

            // If we're still here then the movie should be included.
            return true
        }
    }

    var body: some View {
        NavigationStack {
            List(searchResults) { movie in
                Text(movie.name)
            }
            .navigationTitle("Movies+")
            .searchable(
		            text: $searchText, 
		            tokens: $currentTokens, 
		            suggestedTokens: .constant(suggestedTokens), 
		            prompt: Text("Type to filter, or use # for tags")
		        ) { token in
                Text(token.name)
            }
        }
    }
}

<aside> 💡 在实践中,您有可能将多个标签附加到正在使用的每条数据上,在这种情况下,我可能更喜欢通过 isSuperset(of:) 来设置(将用户选择的标签来和你的对象进行匹配)。如果您正在使用大量令牌,我建议您根据用户到目前为止输入的内容过滤建议令牌列表。

</aside>

<aside> 💡 最后:虽然 searchable() 的 iOS 自动补全,会用建议的标记替换您输入的搜索结果;但在 macOS 上不会发生这种情况。相反,您的搜索令牌会在搜索框下方显示为弹出窗口,同时使您的搜索结果可见 - 这是一种更好的体验。

</aside>

闭包:搜索建议

searchable() 允许我们显示建议列表,甚至添加额外的完成信息以节省用户打字时间,这是通过添加一个闭包来实现的。该闭包返回您建议的视图,如果希望用户能点击完成搜索,则为每个建议视图添加 searchCompletion() 修饰符。

struct ContentView: View {
    let names = ["Holly", "Josh", "Rhonda", "Ted"]
    @State private var searchText = ""

    var body: some View {
        NavigationStack {
            List {
                ForEach(searchResults, id: \\.self) { name in
                    NavigationLink {
                        Text(name)
                    } label: {
                        Text(name)
                    }
                }
            }
            .navigationTitle("Contacts")
        }
        // 闭包中的 result 就是搜索库中匹配出来的一个个元素
        .searchable(text: $searchText) {
            ForEach(searchResults, id: \\.self) { result in
		            // 加上 searchCompletion 修饰符,点击后自动补全元素
                Text("Are you looking for \\(result)?").searchCompletion(result)
            }
        }
    }

		// 设置计算属性:计算搜索库(该例子中就是 names 数组)是否有包含当前输入的搜索字符的元素
    var searchResults: [String] {
        if searchText.isEmpty {
            return names
        } else {
		        // 从 names 数组中检查是否包含当前输入的搜索字符,如果有就返回显示,方便用户直接点击
            return names.filter { $0.contains(searchText) }
        }
    }
    
}

使用搜索框过滤列表数据

实现这个功能只需要遵循基本4个步骤:

1. 状态属性存储搜索文本

//首先新建 @State 属性,开始存储用户正在搜索的文本
@State private var searchText = ""

2. 添加 .searchable

NavigationStack {
		Text("Searching for \\(searchText)")
		// 将 searchable 修饰符添加到 NavigationStack 里,并绑定前面的状态属性
		.searchable(
        text: $dataController.filterText,
        prompt: "Filter issues, or type # to add tags"
    ) 
    { tag in
	        Text(tag.tagName)
		}
}

3. 计算属性处理数据过滤

实际上 searchable 最好与某种数据过滤一起使用。请记住,当 @State 属性更改时,将重新调用 body 属性,因此可以使用计算属性来处理过滤。如果 searchText 属性为空,那么发回加载的所有数据;否则使用 localizedStandardContains() 根据搜索条件过滤数组。

//新建一个计算属性,对数据进行过滤
var filteredResorts: [Resort] {
    if searchText.isEmpty {
        return resorts
    } else {
        return resorts.filter { $0.name.localizedStandardContains(searchText) }
    }
}

//当运行时,iOS会自动隐藏列表最顶部的搜索栏,你需要轻轻地向下拉才能显示它,这与其他iOS应用程序的工作方式相匹配

<aside> 💡 提示:相比使用 contains() 方法,这里使用的是 localizedCaseInsensitiveContains() 方法,该方法使我们可以检查搜索字符串的任何部分,而不必担心大写或小写字母。

</aside>

4. 计算属性作为数据源

最后一步是让计算属性 filteredResorts 作为列表的数据源。

List(filteredResorts) { resort in ...}
//完整代码
struct ContentView: View {
    
    //常量:加载所有休闲度假胜地
    let resorts: [Resort] = Bundle.main.decode("resorts.json")
    
    //状态参数:检索词
    @State private var searchText = ""
    
    //需要一个计算属性来处理数据过滤
    var filteredResorts: [Resort] {
        if searchText.isEmpty {
            return resorts
        } else {
            return resorts.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
        }
    }
   
    var body: some View {
        NavigationSplitView(
            sidebar: {
                List(filteredResorts) {
                    resort in
										...
                }
                .navigationTitle("Resorts")
                .searchable(text: $searchText, prompt: "Search for a resort")
            },
            detail: { ... }
        )
    }

}

searchScopes 限定搜索范围

对于更高级的搜索,您可以通过向搜索框添加 searchScopes() 修饰符,从而指定范围以便用户来选择想要的搜索类型。这需要绑定到某个“监听当前活跃搜索范围”的状态属性,然后使用尾随闭包提供具体范围。

例如,我们可以编写一些代码,让用户选择搜索所有收件箱或仅搜索他们最喜欢的消息,如下所示:

struct Message: Identifiable, Codable {
    let id: Int
    var user: String
    var text: String
}

// 定义一个搜索范围的枚举
enum SearchScope: String, CaseIterable {
    case inbox, favorites
}

struct ContentView: View {
    @State private var messages = [Message]()
    @State private var searchText = ""
    @State private var searchScope = SearchScope.inbox

    var body: some View {
        NavigationStack {
            List {
                ForEach(filteredMessages) { message in
                    VStack(alignment: .leading) {
                        Text(message.user).font(.headline)
                        Text(message.text)
                    }
                }
            }
            .navigationTitle("Messages")
        }
        .searchable(text: $searchText)
        .searchScopes($searchScope) {
            ForEach(SearchScope.allCases, id: \\.self) { scope in
                Text(scope.rawValue.capitalized)
            }
        }
        .onAppear(perform: runSearch)
        .onSubmit(of: .search, runSearch)
        .onChange(of: searchScope) { _ in runSearch() }
    }

    var filteredMessages: [Message] {
        if searchText.isEmpty {
            return messages
        } else {
            return messages.filter { $0.text.localizedCaseInsensitiveContains(searchText) }
        }
    }

    func runSearch() {
        Task {
            guard let url = URL(string: "<https://hws.dev/\\(searchScope.rawValue).json>") else { return }
            let (data, _) = try await URLSession.shared.data(from: url)
            messages = try JSONDecoder().decode([Message].self, from: data)
        }
    }
    
}