本文是SwiftUI基礎教程基礎教程的第二部分,第一部分參見SwiftUI基礎教程(1)。
本文是SwiftUI基礎教程基礎教程的第二部分,第一部分參見SwiftUI基礎教程(2)。
第二章:SwiftUI基礎元素實例
2.14 Combine組件基本應用1
import Foundation
import Combine
// SettingStore.swift
// Int 是值DisplayOrderType的基礎值
// CaseIterable 是使DisplayOrderType具有迭代能力
enum DisplayOrderType: Int, CaseIterable {
case alphabetical = 0
case favoriteFirst = 1
case checkInFirst = 2
// 枚舉在Swift中是一級對象,可以具有成員函數
init(type: Int) {
switch type {
case 0: self = .alphabetical
case 1: self = .favoriteFirst
case 2: self = .checkInFirst
default: self = .alphabetical
}
}
var text: String {
switch self {
case .alphabetical: return "Alphabetical"
case .favoriteFirst: return "Show Favorite First"
case .checkInFirst: return "Show Check-in First"
}
}
// 在排序時會自動調用
func predicate() -> ((ATankInfoForm, ATankInfoForm) -> Bool) {
switch self {
case .alphabetical: return { $0.name < $1.name }
case .favoriteFirst: return { $0.isFavorite && !$1.isFavorite }
case .checkInFirst: return { $0.isCheckIn && !$1.isCheckIn }
}
}
}
// ObservableObject使SettingStore能夠在發生變化時被監聽到
final class SettingStore: ObservableObject {
init() {
// 這個是默認值,在程序中第一次能夠被執行
UserDefaults.standard.register(defaults: [
"view.preferences.showCheckInOnly" : false,
"view.preferences.displayOrder" : 0,
"view.preferences.maxPriceLevel" : 5
])
}
// 添加@Published的屬性能夠在變化時被其他對象感知
@Published var showCheckInOnly: Bool = UserDefaults.standard.bool(forKey: "view.preferences.showCheckInOnly") {
// 類似OC的KVO,在屬性被賦值時自動執行
didSet {
UserDefaults.standard.set(showCheckInOnly, forKey: "view.preferences.showCheckInOnly")
}
}
@Published var displayOrder: DisplayOrderType = DisplayOrderType(type: UserDefaults.standard.integer(forKey: "view.preferences.displayOrder")) {
didSet {
UserDefaults.standard.set(displayOrder.rawValue, forKey: "view.preferences.displayOrder")
}
}
@Published var maxPriceLevel: Int = UserDefaults.standard.integer(forKey: "view.preferences.maxPriceLevel") {
didSet {
UserDefaults.standard.set(maxPriceLevel, forKey: "view.preferences.maxPriceLevel")
}
}
}
struct SettingView: View {
// 類似全局變量,可以在app範圍內訪問
@Environment(\.presentationMode) var presentationMode
@State private var selectedOrder = DisplayOrderType.alphabetical
@State private var showCheckInOnly = false
@State private var maxPriceLevel = 5
@EnvironmentObject var settingStore: SettingStore
var body: some View {
NavigationView {
Form {
Section(header: Text("SORT PREFERENCE")) {
Picker(selection: $selectedOrder, label: Text("Display order")) {
ForEach(DisplayOrderType.allCases, id: \.self) {
orderType in
Text(orderType.text)
}
}
}
Section(header: Text("FILTER PREFERENCE")) {
Toggle(isOn: $showCheckInOnly) {
Text("Show Check-in Only")
}
Stepper(onIncrement: {
self.maxPriceLevel += 1
if self.maxPriceLevel > 5 {
self.maxPriceLevel = 5
}
}, onDecrement: {
self.maxPriceLevel -= 1
if self.maxPriceLevel < 1 {
self.maxPriceLevel = 1
}
}) {
Text("Show \(String(repeating: "$", count: maxPriceLevel)) or below")
}
}
}
.navigationBarTitle("Settings")
.navigationBarItems(leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Cancel")
.foregroundColor(.black)
})
, trailing:
Button(action: {
self.settingStore.showCheckInOnly = self.showCheckInOnly
self.settingStore.displayOrder = self.selectedOrder
self.settingStore.maxPriceLevel = self.maxPriceLevel
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Save")
.foregroundColor(.black)
})
)
}
.onAppear {
self.selectedOrder = self.settingStore.displayOrder
self.showCheckInOnly = self.settingStore.showCheckInOnly
self.maxPriceLevel = self.settingStore.maxPriceLevel
}
}
}
struct SettingView_Previews: PreviewProvider {
static var previews: some View {
SettingView().environmentObject(SettingStore())
}
}
struct SwiftUIForm: View {
@State var tankInfos = [
ATankInfoForm(name: "M1A2", image: "1", type:"美國", phone: "119", priceLevel: 3, isFavorite: true, isCheckIn: false),
ATankInfoForm(name: "T90", image: "2", type:"俄羅斯", phone: "119", priceLevel: 2, isFavorite: false, isCheckIn: true),
ATankInfoForm(name: "T72", image: "3", type:"俄羅斯", phone: "119", priceLevel: 2, isFavorite: false, isCheckIn: true),
ATankInfoForm(name: "99A", image: "4", type:"俄羅斯", phone: "119", priceLevel: 2, isFavorite: false, isCheckIn: true),
ATankInfoForm(name: "LieKeLieEr", image: "5", type:"德國", phone: "123", priceLevel: 5, isFavorite: true, isCheckIn: true),
ATankInfoForm(name: "BaoEr", image: "6", type:"德國", phone: "123", priceLevel: 2, isFavorite: false, isCheckIn: true),
ATankInfoForm(name: "09", image: "7", type:"日版", phone: "777", priceLevel: 1, isFavorite: true, isCheckIn: true),
ATankInfoForm(name: "10", image: "8", type:"日本", phone: "911", priceLevel: 4, isFavorite: false, isCheckIn: false),
]
@State private var selectedRestaurant: ATankInfoForm?
@State private var showSettings: Bool = false
@EnvironmentObject var settingStore: SettingStore
var body: some View {
NavigationView {
List {
ForEach(tankInfos.sorted(by: self.settingStore.displayOrder.predicate())) { aTankInfo in
if self.shouldShowItem(tankInfo: aTankInfo) {
BasicImageRow(restaurant: aTankInfo)
.contextMenu {
Button(action: {
// mark the selected restaurant as check-in
self.checkIn(item: aTankInfo)
}) {
HStack {
Text("Check-in")
Image(systemName: "checkmark.seal.fill")
}
}
Button(action: {
// delete the selected restaurant
self.delete(item: aTankInfo)
}) {
HStack {
Text("Delete")
Image(systemName: "trash")
}
}
Button(action: {
// mark the selected restaurant as favorite
self.setFavorite(item: aTankInfo)
}) {
HStack {
Text("Favorite")
Image(systemName: "star")
}
}
}
.onTapGesture {
self.selectedRestaurant = aTankInfo
}
}
}
.onDelete { (indexSet) in
self.tankInfos.remove(atOffsets: indexSet)
}
}
.navigationBarTitle("Restaurant")
.navigationBarItems(trailing:
Button(action: {
self.showSettings = true
}, label: {
Image(systemName: "gear").font(.title)
.foregroundColor(.black)
})
)
.sheet(isPresented: $showSettings) {
SettingView().environmentObject(self.settingStore)
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private func delete(item restaurant: ATankInfoForm) {
if let index = self.tankInfos.firstIndex(where: { $0.id == restaurant.id }) {
self.tankInfos.remove(at: index)
}
}
private func setFavorite(item restaurant: ATankInfoForm) {
if let index = self.tankInfos.firstIndex(where: { $0.id == restaurant.id }) {
self.tankInfos[index].isFavorite.toggle()
}
}
private func checkIn(item restaurant: ATankInfoForm) {
if let index = self.tankInfos.firstIndex(where: { $0.id == restaurant.id }) {
self.tankInfos[index].isCheckIn.toggle()
}
}
private func shouldShowItem(tankInfo: ATankInfoForm) -> Bool {
return (!self.settingStore.showCheckInOnly || tankInfo.isCheckIn) && (tankInfo.priceLevel <= self.settingStore.maxPriceLevel)
}
}
struct SwiftUIForm_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(SettingStore())
}
}
struct BasicImageRow: View {
var restaurant: ATankInfoForm
var body: some View {
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 60, height: 60)
.clipShape(Circle())
.padding(.trailing, 10)
VStack(alignment: .leading) {
HStack {
Text(restaurant.name)
.font(.system(.body, design: .rounded))
.bold()
Text(String(repeating: "$", count: restaurant.priceLevel))
.font(.subheadline)
.foregroundColor(.gray)
}
Text(restaurant.type)
.font(.system(.subheadline, design: .rounded))
.bold()
.foregroundColor(.secondary)
.lineLimit(3)
Text(restaurant.phone)
.font(.system(.subheadline, design: .rounded))
.foregroundColor(.secondary)
}
Spacer()
.layoutPriority(-100)
if restaurant.isCheckIn {
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.red)
}
if restaurant.isFavorite {
// Spacer()
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}
}
struct ATankInfoForm: Identifiable {
let id = UUID()
var name: String
var image: String
var type: String
var phone: String
var priceLevel: Int
var isFavorite: Bool = false
var isCheckIn: Bool = false
}
2.15 Combine組件基本應用2
// 本代碼構建了一個用於註冊的UI,其中UI部分不做介紹,對於涉及Combine的做簡單介紹
struct SwiftUIRegister: View {
// @ObservedObject 標明這個是一個可以被觀察的對象(別人修改它,這裏自動刷新UI)
@ObservedObject private var userRegistrationViewModel = UserRegistrationViewModel()
var body: some View {
VStack {
Text("Create an account")
.font(.system(.largeTitle, design: .rounded))
.bold()
.padding(.bottom, 30)
// $userRegistrationViewModel.username 標明把此處的值傳遞到其他的View
FormField(fieldName: "Username", fieldValue: $userRegistrationViewModel.username)
RequirementText(iconColor: userRegistrationViewModel.isUsernameLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "A minimum of 4 characters", isStrikeThrough: userRegistrationViewModel.isUsernameLengthValid)
.padding()
FormField(fieldName: "Password", fieldValue: $userRegistrationViewModel.password, isSecure: true)
VStack {
RequirementText(iconName: "lock.open", iconColor: userRegistrationViewModel.isPasswordLengthValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "A minimum of 8 characters", isStrikeThrough: userRegistrationViewModel.isPasswordLengthValid)
RequirementText(iconName: "lock.open", iconColor: userRegistrationViewModel.isPasswordCapitalLetter ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "One uppercase letter", isStrikeThrough: userRegistrationViewModel.isPasswordCapitalLetter)
}
.padding()
FormField(fieldName: "Confirm Password", fieldValue: $userRegistrationViewModel.passwordConfirm, isSecure: true)
RequirementText(iconColor: userRegistrationViewModel.isPasswordConfirmValid ? Color.secondary : Color(red: 251/255, green: 128/255, blue: 128/255), text: "Your confirm password should be the same as password", isStrikeThrough: userRegistrationViewModel.isPasswordConfirmValid)
.padding()
.padding(.bottom, 50)
Button(action: {
// Proceed to the next screen
}) {
Text("Sign Up")
.font(.system(.body, design: .rounded))
.foregroundColor(.white)
.bold()
.padding()
.frame(minWidth: 0, maxWidth: .infinity)
.background(LinearGradient(gradient: Gradient(colors: [Color(red: 251/255, green: 128/255, blue: 128/255), Color(red: 253/255, green: 193/255, blue: 104/255)]), startPoint: .leading, endPoint: .trailing))
.cornerRadius(10)
.padding(.horizontal)
}
HStack {
Text("Already have an account?")
.font(.system(.body, design: .rounded))
.bold()
Button(action: {
// Proceed to Sign in screen
}) {
Text("Sign in")
.font(.system(.body, design: .rounded))
.bold()
.foregroundColor(Color(red: 251/255, green: 128/255, blue: 128/255))
}
}.padding(.top, 50)
Spacer()
}
.padding()
}
}
struct SwiftUIRegister_Previews: PreviewProvider {
static var previews: some View {
SwiftUIRegister()
}
}
struct FormField: View {
var fieldName = ""
@Binding var fieldValue: String
var isSecure = false
var body: some View {
VStack {
if isSecure {
SecureField(fieldName, text: $fieldValue)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.padding(.horizontal)
} else {
TextField(fieldName, text: $fieldValue) .font(.system(size: 20, weight: .semibold, design: .rounded))
.padding(.horizontal)
}
Divider()
.frame(height: 1)
.background(Color(red: 240/255, green: 240/255, blue: 240/255))
.padding(.horizontal)
}
}
}
struct RequirementText: View {
var iconName = "xmark.square"
var iconColor = Color(red: 251/255, green: 128/255, blue: 128/255)
var text = ""
var isStrikeThrough = false
var body: some View {
HStack {
Image(systemName: iconName)
.foregroundColor(iconColor)
Text(text)
.font(.system(.body, design: .rounded))
.foregroundColor(.secondary)
.strikethrough(isStrikeThrough)
Spacer()
}
}
}
import Foundation
import Combine
class UserRegistrationViewModel: ObservableObject {
// @Published標明這個值的變化會被其他對象感知
// Input
@Published var username = ""
@Published var password = ""
@Published var passwordConfirm = ""
// Output
@Published var isUsernameLengthValid = false
@Published var isPasswordLengthValid = false
@Published var isPasswordCapitalLetter = false
@Published var isPasswordConfirmValid = false
private var cancellableSet: Set<AnyCancellable> = []
init() {
$username
.receive(on: RunLoop.main) // 標明這個值的改變會被在主線程監聽
.map { username in // 接收到新的值之後要做的處理
return username.count >= 4
}
.assign(to: \.isUsernameLengthValid, on: self) // 根據收到的消息,對其他值進行修改,由於isUsernameLengthValid也是一個被訂閱對象,所以其改動會導致其他監聽被調用(此處是SwiftUIRegister被自動刷新)
.store(in: &cancellableSet) // 爲了防止內存泄漏
$password
.receive(on: RunLoop.main)
.map { password in
return password.count >= 8
}
.assign(to: \.isPasswordLengthValid, on: self)
.store(in: &cancellableSet)
$password
.receive(on: RunLoop.main)
.map { password in
let pattern = "[A-Z]"
if let _ = password.range(of: pattern, options: .regularExpression) {
return true
} else {
return false
}
}
.assign(to: \.isPasswordCapitalLetter, on: self)
.store(in: &cancellableSet)
Publishers.CombineLatest($password, $passwordConfirm) // 同時監聽兩個值要怎麼辦
.receive(on: RunLoop.main)
.map { (password, passwordConfirm) in
return !passwordConfirm.isEmpty && (passwordConfirm == password)
}
.assign(to: \.isPasswordConfirmValid, on: self)
.store(in: &cancellableSet)
}
}
以上代碼的UI如下圖所示。