HackingWithSwift-2
https://www.youtube.com/playlist?list=PLuoeXyslFTuZRi4q4VT6lZKxYbr7so1Mr
Moonshot
核心代码
Bundle-Decodable
swift
extension Bundle {
func decode<T: Decodable>(_ file: String) -> T {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed to locate \(file) in bundle")
}
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.dateFormat = "y-MM-dd"
decoder.dateDecodingStrategy = .formatted(formatter)
guard let loaded = try? decoder.decode( T.self, from: data) else {
fatalError("Failed to locate \(file) in bundle")
}
return loaded
}
}
Color-Theme
swift
import SwiftUI
extension ShapeStyle where Self == Color {
static var darkBackground: Color {
Color(red: 0.1, green: 0.1, blue: 0.2)
}
static var lightBackgroud: Color {
Color(red: 0.2, green: 0.2, blue: 0.3)
}
}
Model
swift
struct Astronaut: Codable, Identifiable {
let id: String
let name: String
let description: String
}
struct Mission: Codable, Identifiable {
struct CrewRole: Codable {
let name: String
let role: String
}
let id: Int
let launchDate: Date?
let crew: [CrewRole]
let description: String
var displayName: String {
"Apollo \(id)"
}
var image: String {
"apollo\(id)"
}
var formattedLaunchDate: String {
launchDate?.formatted(date: .abbreviated, time: .omitted) ?? "N/A"
}
}
ContentView
swift
struct ContentView: View {
let astronauts: [String: Astronaut] = Bundle.main.decode("astronauts.json")
let missions: [Mission] = Bundle.main.decode("missions.json")
let columns = [
GridItem(.adaptive(minimum: 150))
]
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(missions) { mission in
NavigationLink{
MissionView(mission: mission, astronauts: astronauts)
} label: {
VStack{
Image(mission.image)
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
VStack {
Text(mission.displayName)
.font(.headline)
.foregroundColor(.white)
Text(mission.formattedLaunchDate)
.font(.subheadline)
.foregroundColor(.white.opacity(0.5))
}
.padding(.vertical)
.frame(maxWidth: .infinity)
.background(.lightBackgroud)
}
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay{
RoundedRectangle(cornerRadius: 10)
.stroke()
}
}
}
}
.padding([.horizontal, .bottom])
}
.navigationTitle("Moonshot")
.background(.darkBackground)
.preferredColorScheme(.dark)
}
}
}
MissionView
swift
struct MissionView: View {
struct CrewMember {
let role: String
let astronaut: Astronaut
}
let mission: Mission
let crew: [CrewMember]
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack {
Image(mission.image)
.resizable()
.scaledToFit()
.frame(maxWidth: geometry.size.width * 0.6)
.padding(.top)
VStack(alignment: .leading) {
Rectangle()
.frame(height: 2)
.foregroundColor(.lightBackgroud)
.padding(.vertical)
Text("Mission Highlights")
.font(.title.bold())
.padding(.bottom, 5)
Text(mission.description)
Rectangle()
.frame(height: 2)
.foregroundColor(.lightBackgroud)
.padding(.vertical)
Text("Crew")
.font(.title.bold())
.padding(.bottom, 5)
}
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(crew, id: \.role) { crewMember in
NavigationLink {
AstronautView(astronaut: crewMember.astronaut)
}label: {
HStack {
Image(crewMember.astronaut.id)
.resizable()
.frame(width: 104, height: 72)
.clipShape(Capsule())
.overlay{
Capsule()
.strokeBorder(.white, lineWidth: 1)
}
VStack(alignment: .leading) {
Text(crewMember.astronaut.name)
.foregroundColor(.white)
.font(.headline)
Text(crewMember.role)
.foregroundColor(.secondary)
}
}
}
}
}
}
.padding(.horizontal)
}
.padding(.bottom)
}
.navigationTitle(mission.displayName)
.navigationBarTitleDisplayMode(.inline)
.background(.darkBackground)
}
}
init(mission: Mission, astronauts: [String: Astronaut]) {
self.mission = mission
self.crew = mission.crew.map { member in
if let astronaut = astronauts[member.name] {
return CrewMember(role: member.role, astronaut: astronaut)
}else {
fatalError("Missing \(member.name)")
}
}
}
}
AstronautView
swift
struct AstronautView: View {
let astronaut: Astronaut
var body: some View {
ScrollView {
VStack {
Image(astronaut.id)
.resizable()
.scaledToFit()
Text(astronaut.description)
.padding()
}
}
.background(.darkBackground)
.navigationTitle(astronaut.name)
.navigationBarTitleDisplayMode(.inline)
}
}
知识点
- extension扩展Bubble读取静态资源文件、扩展Color用于设置背景色
- LazyVGrid设置自适应的网格视图
- .preferredColorScheme(.dark)设置首选项颜色
- GeometryReader,可以获取父View的坐标、尺寸等
- Image调整大小- resizable(), scaleToFit(),frame()
效果
CupcakeCorner
核心代码
Order
swift
class Order: ObservableObject, Codable {
enum CodingKeys: CodingKey {
case type, quantity, extraFrosting, addSprinkles, name, streetAddress, city, zip
}
static let types = ["Vanilla", "Strawberry", "Chocolate", "Rainbow"]
@Published var type = 0
@Published var quantity = 3
@Published var specialRequestEnabled = false {
didSet {
if specialRequestEnabled == false {
extraFrosting = false
addSprinkles = false
}
}
}
@Published var extraFrosting = false
@Published var addSprinkles = false
@Published var name = ""
@Published var streetAddress = ""
@Published var city = ""
@Published var zip = ""
var hasValidAddress: Bool {
if name.isEmpty || streetAddress.isEmpty || city.isEmpty || zip.isEmpty {
return false
}
return true
}
var cost: Double {
// $2 per cake
var cost = Double(quantity) * 2
// complicated cakes cost more
cost += Double(type) / 2
// $1/cake for extra frosting
if extraFrosting {
cost += Double(quantity)
}
// $0.5/cake for sprinkles
if addSprinkles {
cost += Double(quantity) / 2
}
return cost
}
init() {}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(type, forKey: .type)
try container.encode(quantity, forKey: .quantity)
try container.encode(extraFrosting, forKey: .extraFrosting)
try container.encode(addSprinkles, forKey: .addSprinkles)
try container.encode(name, forKey: .name)
try container.encode(streetAddress, forKey: .streetAddress)
try container.encode(city, forKey: .city)
try container.encode(zip, forKey: .zip)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decode(Int.self, forKey: .type)
quantity = try container.decode(Int.self, forKey: .quantity)
extraFrosting = try container.decode(Bool.self, forKey: .extraFrosting)
addSprinkles = try container.decode(Bool.self, forKey: .addSprinkles)
name = try container.decode(String.self, forKey: .name)
city = try container.decode(String.self, forKey: .city)
streetAddress = try container.decode(String.self, forKey: .streetAddress)
zip = try container.decode(String.self, forKey: .zip)
}
}
ContentView
swift
struct ContentView: View {
@StateObject var order = Order()
var body: some View {
NavigationView{
Form {
Section {
Picker("Select your cake type", selection: $order.type) {
ForEach(Order.types.indices, id: \.self) {
Text(Order.types[$0])
}
}
Stepper("Number of cakes: \(order.quantity)", value: $order.quantity, in: 3...20)
}
Section {
Toggle("Any special requests?", isOn: $order.specialRequestEnabled.animation())
if order.specialRequestEnabled {
Toggle("Add extra frosting", isOn: $order.extraFrosting)
Toggle("Add extra sprinkles", isOn: $order.addSprinkles)
}
}
Section {
NavigationLink {
AddressView(order: order)
} label: {
Text("Deliver details")
}
}
}
.navigationTitle("Cupcake Corner")
}
}
}
AddressView
swift
struct AddressView: View {
@ObservedObject var order: Order
var body: some View {
Form {
Section {
TextField("name", text: $order.name)
TextField("Street address", text: $order.streetAddress)
TextField("City", text: $order.city)
TextField("Zip", text: $order.zip)
}
Section {
NavigationLink {
CheckoutView(order: order)
} label: {
Text("Check out")
}
}
.disabled(!order.hasValidAddress)
}
.navigationTitle("Delivery details")
.navigationBarTitleDisplayMode(.inline)
}
}
CheckoutView
swift
struct CheckoutView: View {
@ObservedObject var order: Order
@State private var confirmationMessage = ""
@State private var showingConfirmation = false
var body: some View {
ScrollView {
VStack {
AsyncImage(url: URL(string: "https://hws.dev/img/cupcakes@3x.jpg"), scale: 3) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
ProgressView()
}
Text("Your total is \(order.cost, format: .currency(code: "USD"))")
.font(.title)
Button("Place Order") {
Task {
await placeOrder()
}
}
.padding()
}
}
.navigationTitle("Check out")
.navigationBarTitleDisplayMode(.inline)
.alert("Thank you!", isPresented: $showingConfirmation) {
Button("OK") {}
} message: {
Text(confirmationMessage)
}
}
func placeOrder() async {
guard let encoded = try? JSONEncoder().encode(order) else {
print("Failed to encode order")
return
}
let url = URL(string: "https://reqres.in/api/cupcakes")!
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
do {
let (data, _) = try await URLSession.shared.upload(for: request, from: encoded)
let decodedOrder = try JSONDecoder().decode(Order.self, from: data)
confirmationMessage = "Your order for \(decodedOrder.quantity)x\(Order.types[decodedOrder.type].lowercased()) cupcakes is on its way !"
showingConfirmation = true
} catch {
print("Check out failed ...")
}
}
}
知识点
Codable协议无法处理被@Published等属性包装器修饰的属性,需要额外编码来手动实现Codable协议
Form表单校验使用
.disabled()
修饰符异步加载图像
AsyncImage
,这个View不能像普通Image一样直接使用.resizable()
调整大小,需要特殊处理swiftAsyncImage(url: URL(string: "https://hws.dev/img/cupcakes@3x.jpg"), scale: 3) { image in image .resizable() .scaledToFit() } placeholder: { ProgressView() //加载中loading view }
Button的action使用异步方法时需要使用Task
swiftButton("Place Order") { Task { await placeOrder() } }
使用URLRequest、URLSession发送http请求
异步方法,async,await关键字
效果
Bookworm
核心代码
BookwormApp
swift
@main
struct BookwormApp: App {
@StateObject private var dataController = DataController()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, dataController.container.viewContext )
}
}
}
ContentView
swift
struct ContentView: View {
@Environment(\.managedObjectContext) var moc
@FetchRequest(sortDescriptors: [SortDescriptor(\.title, order: .forward), SortDescriptor(\.author, order: .forward)]) var books: FetchedResults<Book>
@State private var showingAddScreen = false
var body: some View {
NavigationView {
List {
ForEach(books) { book in
NavigationLink {
DetailView(book: book)
} label: {
HStack {
EmojiRatingView(rating: book.rating)
.font(.largeTitle)
VStack(alignment: .leading) {
Text(book.title ?? "Unknown title")
.font(.headline)
Text(book.author ?? "Unknown author")
.foregroundColor(.secondary)
}
}
}
}
.onDelete(perform: deleteBooks)
}
.navigationTitle("Bookworm")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingAddScreen.toggle()
} label: {
Label("Add book", systemImage: "plus")
}
}
}
.sheet(isPresented: $showingAddScreen) {
AddBookView()
}
}
}
func deleteBooks(at offsets: IndexSet) {
for offset in offsets {
let book = books[offset]
moc.delete(book)
}
try? moc.save()
}
}
DataController
swift
import CoreData
class DataController: ObservableObject {
let container = NSPersistentContainer(name: "Bookworm")
init() {
container.loadPersistentStores { description, error in
if let error = error {
print("Core data failed to load: \(error.localizedDescription)")
}
}
}
}
AddBookView
swift
struct AddBookView: View {
@Environment(\.managedObjectContext) var moc
@Environment(\.dismiss) var dismiss
@State private var title = ""
@State private var author = ""
@State private var rating = 3
@State private var genre = ""
@State private var review = ""
let genres = ["Fantasy", "Horror", "Kids", "Mystery", "Poetry", "Romance", "Thriller"]
var body: some View {
NavigationView {
Form {
Section {
TextField("Name of book", text: $title)
TextField("Author's name", text: $author)
Picker("Genre", selection: $genre) {
ForEach(genres, id: \.self) {
Text($0)
}
}
}
Section {
TextEditor(text: $review)
RatingView(rating: $rating)
} header: {
Text("Write a review")
}
Section {
Button("Save") {
let book = Book(context: moc)
book.id = UUID()
book.title = self.title
book.author = self.author
book.genre = self.genre
book.review = self.review
book.rating = Int16(self.rating)
try? moc.save()
dismiss()
}
}
}
.navigationTitle("Add book")
}
}
}
RatingView
swift
struct RatingView: View {
@Binding var rating: Int
var label = ""
var maxmiumRating = 5
var offImage: Image?
var onImage = Image(systemName: "star.fill")
var offColor = Color.gray
var onColor = Color.yellow
var body: some View {
HStack {
if label.isEmpty == false {
Text(label)
}
ForEach(1..<maxmiumRating + 1, id: \.self) { number in
image(for: number)
.foregroundColor(number > rating ? offColor : onColor)
.onTapGesture {
rating = number
}
}
}
}
func image(for number: Int) -> Image {
if number > rating {
return offImage ?? onImage
} else {
return onImage
}
}
}
EmojiRatingView
swift
struct EmojiRatingView: View {
let rating: Int16
var body: some View {
switch rating {
case 1:
return Text("☹️")
case 2:
return Text("😞")
case 3:
return Text("😊")
case 4:
return Text("😍")
default:
return Text("🤩")
}
}
}
DetailView
swift
struct DetailView: View {
let book: Book
@Environment(\.managedObjectContext) var moc
@Environment(\.dismiss) var dismiss
@State private var showingAlert = false
var body: some View {
ScrollView {
ZStack(alignment: .bottomTrailing) {
Image(book.genre ?? "Fantasy")
.resizable()
.scaledToFit()
Text(book.genre?.uppercased() ?? "FANTASY")
.font(.caption)
.fontWeight(.black)
.padding(8)
.foregroundColor(.white)
.background(.black.opacity(0.75))
.clipShape(Capsule())
.offset(x: -5, y: -5)
}
Text(book.author ?? "Unknown author")
.font(.title)
.foregroundColor(.secondary)
Text(book.review ?? "No review")
.padding()
RatingView(rating: .constant(Int(book.rating)))
}
.navigationTitle(book.title ?? "Unknown book")
.navigationBarTitleDisplayMode(.inline)
.alert("Delete this book?", isPresented: $showingAlert) {
Button("OK", role: .destructive,action: deleteBook)
Button("Cancle", role: .cancel) { }
} message: {
Text("Are you sure?")
}
.toolbar {
Button {
showingAlert = true
} label: {
Label("Delete this book", systemImage: "trash")
}
}
}
func deleteBook() {
moc.delete(book)
try? moc.save()
dismiss()
}
}
Bookworm.xcdatamodeld
知识点
- CoreData相关概念,.xcdatamodeld文件、NSPersistentContainer、@Environment(.managedObjectContext)、context.save()、context.delete(xxx)、FetchRequest
- TextEditor
- 通用RatingView
效果
CoreData
- Conditional saving of NSManagedObjectContex
swift
@Environment(\.managedObjectContext) var moc
var body: some View {
Button("Save") {
if moc.hasChanges {
try? moc.save()
}
}
}
Ensuring Core Data objects are unique using constraints
- 配置CoreDataEntity的constranits
- DataController增加
swiftself.container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
Dynamically filtering @FetchRequest with SwiftUI
swift
struct FilteredList<T: NSManagedOBject, Context: View>: View {
@FetchRequest var fetchRequest: FetchedResults<T>
var body: some View {
List(fetchRequest, id:\.self) { item in
self.content(item)
}
}
init(filterKey: String, filterValue: String, @ViewBuilder content: @escaping (T) -> Content) {
_fetchRequest = FetchRequest<T>(sortDescriptors: [], predicate: NSPredicate(format: "%K BEGINSWITH %@", filterKey, filterValue))
self.content = content
}
}