自定义手势

长按手势.onLongPressGesture

import SwiftUI

struct LongPressGestureDemo: View {

    @State var isComplete: Bool = false
    @State var isSuccess: Bool = false

    var body: some View {
        //长按手势的基本演示:
//        Text( isComplete ? "已完成" : "未完成")
//            .padding()
//            .padding(.horizontal)
//            .background(isComplete ? Color.red : Color.green)
//            .cornerRadius(10)
            //基本长按手势
//            .onLongPressGesture {
//                isComplete.toggle()
//            }
            //自定义长按手势: 长按1秒,点击的最大移动距离不超过2,才响应
//            .onLongPressGesture(minimumDuration: 1.0, maximumDistance: 2.0) {
//                isComplete.toggle()
//            }
        
        //下面代码:通过长按与放开,实现进度条的增减
        VStack{
            Rectangle()
                .fill(isSuccess ? Color.green : Color.blue)
                .frame(height: 55)
                .frame(maxWidth: isComplete ? .infinity : 0)
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(Color.gray)
            
            HStack {
                Text("点击增长")
                    .foregroundStyle(Color.white)
                    .padding()
                    .background(.black)
                    .cornerRadius(10)
                    .onLongPressGesture(minimumDuration: 1.0, maximumDistance: 2.0) {
                        //这里的操作会在1秒后的最短时间内被调用
                        withAnimation(.easeInOut){
                            isSuccess = true
                        }
                    } onPressingChanged: { isPress in
                        //这里的isPress会被调用两次,一次是按下按钮时,一次是离开按钮时
                        if isPress{
                            withAnimation(.easeInOut(duration: 1.0)) {
                                isComplete.toggle()
                            }
                        }else{
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                //此时进度条没有到底,中途松开按钮后回退
                                if !isSuccess{
                                    withAnimation(.easeInOut) {
                                        isComplete = false
                                    }
                                }
                            }
                        }
                    }
                
                Text("重置")
                    .foregroundStyle(Color.white)
                    .padding()
                    .background(.black)
                    .cornerRadius(10)
                    .onTapGesture {
                        isComplete = false
                        isSuccess = false
                    }
            }
            
        }
    }
}

#Preview {
    LongPressGestureDemo()
}

模拟器上的运行结果:

在这里插入图片描述

放大/缩小手势MagnifyGesture

学习要点:
1.缩放效果修饰符的使用
2.对MagnifyGesture中 .onChanged 和 .onEnded 的使用和理解

import SwiftUI

struct MagnificationGestureDemo: View {
    
    //本次手势实时缩放增量
    @State var currentAmount: CGFloat = 0
    //最后一次的缩放量
    @State var lastAmount: CGFloat = 0
    
    var body: some View {
        //下面是放大手势的基本演示:
//        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
//            .font(.title)
//            .padding(40)
//            .background(Color.red.cornerRadius(10))
//            .scaleEffect(1 + currentAmount + lastAmount)
//            .gesture(
//                //使用放大手势
//                MagnifyGesture()
//                    //手势拖动中
                      //value 是 MagnifyGesture.Value 类型的手势实例结构体,
                      //完整含义:双指缩放手势在当前瞬间的全部状态数据。
//                    .onChanged({ value in
//                        currentAmount = value.magnification - 1
//                    })
//                    //手势松开瞬间
//                    .onEnded({ value in
//                        lastAmount += currentAmount
//                        currentAmount = 0
//                    })
//            )
        
        //下面的代码:模拟Instagram动态模拟版
        VStack(spacing: 10){
            HStack{
                Circle()
                    .frame(width: 35, height: 35)
                Text("学习swiftui")
                Spacer()
                Image(systemName: "ellipsis")
            }
            .padding(.horizontal)
            
            Rectangle()
                .frame(height: 300)
                .scaleEffect(1 + currentAmount)
                .gesture(
                    MagnifyGesture()
                        .onChanged({ value in
                            currentAmount = value.magnification - 1
                        })
                        .onEnded({ valiu in
                            withAnimation(.easeInOut) {
                                currentAmount = 0
                            }
                        })
                )
            
            HStack{
                Image(systemName: "heart.fill")
                Image(systemName: "text.bubble.fill")
                Spacer()
            }
            .padding(.horizontal)
            .font(.headline)
            
            Text("这是我的照片说明!")
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal)
        }
    }
}

#Preview {
    MagnificationGestureDemo()
}

在模拟器上运行,按住 option,点击鼠标可进行缩放:

在这里插入图片描述

旋转手势RotationGesture

可以用两根手指旋转图像,文本等。在开发中用的并不多。

学习要点:
1.旋转效果修饰符的使用
2.如何设置旋转手势

import SwiftUI

struct RotationGestureDemo: View {
    
    @State var angle:Angle = Angle(degrees: 0)
    
    var body: some View {
        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
            .font(.largeTitle)
            .foregroundStyle(.white)
            .fontWeight(.semibold)
            .padding(50)
            .background(.blue)
            //使用旋转效果修饰符
            .rotationEffect(angle)
            .gesture(
                //设置旋转手势
                RotateGesture()
                    .onChanged({ value in
                        angle = value.rotation
                    })
                    .onEnded({ value in
                        angle = Angle(degrees: 0)
                    })
            )
        
    }
}

#Preview {
    RotationGestureDemo()
}

在这里插入图片描述

拖动手势DragGesture

import SwiftUI

struct DragGestureDemo: View {

    //定义当前位置到拖动位置之间的偏移量
    @State var offset: CGSize = .zero

    var body: some View {
                //下面代码:拖动手势的基本使用:
        //        RoundedRectangle(cornerRadius: 20)
        //            .frame(width: 100, height: 100)
        //            .offset(offset)
        //            .gesture(
        //                DragGesture()
        //                    .onChanged({ value in
        //                        withAnimation(.spring()) {
        //                            offset = value.translation
        //                        }
        //                    })
        //                    .onEnded({ value in
        //                        withAnimation(.spring()) {
        //                            offset = .zero
        //                        }
        //                    })
        //            )

        //下面代码:模仿Tinder或Bumble中的卡片拖动旋转
        ZStack {
            
            VStack{
                //左右移动时的偏移量
                Text("\(offset.width)")
                Spacer()
            }
            
            RoundedRectangle(cornerRadius: 20)
                .frame(width: 300, height: 300)
                .offset(offset)
                .scaleEffect(getScaleAmount())
                .rotationEffect(Angle(degrees: getRotationAmount()))
                .gesture(
                    DragGesture()
                        .onChanged({ value in
                            withAnimation(.spring()) {
                                offset = value.translation
                            }
                        })
                        .onEnded({ value in
                            withAnimation(.spring()) {
                                offset = .zero
                            }
                        })
                )
        }

    }
    //获取缩放比例
    func getScaleAmount() -> CGFloat{
        let max = UIScreen.main.bounds.width / 2
        let currentAmount = abs(offset.width)
        //滑动时相对于起始位置向左/向右的百分比
        let precentage = currentAmount / max
        
        //不需要100%缩放,大致缩小一半即可
        return 1.0 - min(precentage, 0.5) * 0.5
    }
    
    //获取旋转角度
    func getRotationAmount() -> Double{
        let max = UIScreen.main.bounds.width / 2
        let currentAmount = offset.width
        let precentage = currentAmount / max
        let precentageDouble = Double(precentage)
        
        let maxAngle: Double = 10
        return precentageDouble * maxAngle
    }
}

#Preview {
    DragGestureDemo()
}

在这里插入图片描述

基于拖动手势实现一个常见的上拉式注册界面

import SwiftUI

struct DragGestureDemo2: View {

    @State var startingOffsetY: CGFloat = UIScreen.main.bounds.height * 0.85
    @State var currentOffsetY: CGFloat = 0
    @State var endingOffsetY: CGFloat = 0

    var body: some View {
        ZStack {
            Color.green.ignoresSafeArea(.all)

            //想把这个注册视图从底部向上拖
            mySignUpView()
                .offset(y: startingOffsetY)
                .offset(y: currentOffsetY)
                .offset(y: endingOffsetY)
                .gesture(
                    DragGesture()
                        .onChanged({ value in
                            withAnimation(.spring()) {
                                currentOffsetY = value.translation.height
                            }
                        })
                        .onEnded({ value in

                            withAnimation(.spring()) {
                                //当向上拖动的偏移量小于-150时,希望它固定在顶部
                                //只需抵消startingOffsetY
                                if currentOffsetY < -150 {
                                    endingOffsetY = -startingOffsetY
                                    
                                } else if endingOffsetY != 0
                                    && currentOffsetY > 150
                                {
                                    //当它在顶部,并且向下拖动的偏移量大于150时,希望它回到底部
                                    endingOffsetY = 0
                                }
                                currentOffsetY = 0
                            }

                        })
                )

        }
        .ignoresSafeArea(edges: .bottom)
    }
}

//分离注册视图
struct mySignUpView: View {
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "chevron.up")
                .padding(.top)
            Text("注册")
                .font(.headline)
                .fontWeight(.semibold)

            Image(systemName: "flame.fill")
                .resizable()
                .scaledToFit()
                .frame(width: 100, height: 100)

            Text("这是应用程序的描述!我很喜欢swiftui教程,我向我所有朋友推荐并且订阅这个课程!!")
                .multilineTextAlignment(.center)

            Text("创建账户")
                .font(.headline)
                .foregroundStyle(.white)
                .padding()
                .padding(.horizontal)
                .background(Color.black)
                .cornerRadius(10)

            Spacer()
        }
        .frame(maxWidth: .infinity)
        .background(Color.white)
        .cornerRadius(30)
    }
}

#Preview {
    DragGestureDemo2()
}

在这里插入图片描述

滚动视图读取器ScrollViewReader

之前在应用里添加一个基础的滚动视图时,我们能手动上下滚动,但无法自动滚动道底部或中间,也不能在该滚动视图中滚到任意位置。
使用滚动视图读取器可以添加功能,从而实现上面无法做到的操作。

学习要点:
1.使用ScrollViewReader时,要将ID设置为数据集中每个项目的索引,并且使用 协议proxy 调用 .scrollTo。
2. .onChange修饰符的使用:为视图添加一个修饰符,当特定值改变时(这里就是scrollToIndex)触发该修饰符。

import SwiftUI

struct ScrollViewReaderDemo: View {

    @State var textField: String = ""
    @State var scrollToIndex: Int = 0

    var body: some View {
        VStack {

            TextField("请输入项目号.....", text: $textField)
                .frame(height: 55)
                .border(Color.gray)
                .padding(.horizontal)
                //设置键盘类型,使用数字键盘
                .keyboardType(.numberPad)

            //点击这个按钮时,要获取到文本框的数字,并给scrollToIndex
            Button("立即滚动") {
                withAnimation(.spring()) {

                    if let index = Int(textField) {
                        scrollToIndex = index
                    }

                    //参数:要跳转的id号,跳转时出现的位置锚点
                    //proxy.scrollTo(39, anchor: .center)
                }
            }

            ScrollView {
                //这里的proxy是在读取滚动视图的大小,这样它就能知道这些项目的位置了
                ScrollViewReader { proxy in

                    ForEach(0..<50) { index in
                        Text("这是项目# \(index)")
                            .font(.headline)
                            .frame(height: 200)
                            .frame(maxWidth: .infinity)
                            .background(Color.white)
                            .cornerRadius(10)
                            .shadow(radius: 10)
                            .padding()
                            //这是用.id修饰符,就是告诉proxy49号项目的索引是49
                            //将ID设为数据集中每个项目的索引
                            .id(index)
                    }
                    //为视图添加一个修饰符,当特定值改变时(这里就是scrollToIndex)触发该修饰符
                    .onChange(of: scrollToIndex) {
                        withAnimation(.spring()) {
                            proxy.scrollTo(scrollToIndex, anchor: .center)
                        }
                    }
                }

            }
        }
    }
}

#Preview {
    ScrollViewReaderDemo()
}

在这里插入图片描述

几何读取器GeometryReader

能够获取几何图形的精准尺寸,以及屏幕上对象的位置。

但是在一个屏幕上使用多个几何读取器会对用户界面造成卡顿!尽量不使用几何读取器或只使用一个!

import SwiftUI

struct GeometryReaderDemo: View {
    var body: some View {

        //如果想让红色占屏幕的2/3,可以使用UIScreen,但是会有一个问题:
        //当切为横屏时,红色实际连1/3都没占到,因为frame是设备的宽/高。
        //此时就可以使用几何读取器:它会提供当前设备在横/竖屏模式下的都宽/高。

        //这里的geometry会获取图形的尺寸
        //        GeometryReader{ geometry in
        //            HStack(spacing: 0){
        //                Rectangle()
        //                    .fill(Color.red)
        //                    .frame(width: geometry.size.width * 0.6666)
        //
        //                Rectangle()
        //                    .fill(Color.blue)
        //
        //            }
        //            .ignoresSafeArea(.all)
        //        }

        ScrollView(.horizontal) {
            HStack {
                ForEach(1..<50) { index in
                    GeometryReader { geometory in
                        RoundedRectangle(cornerRadius: 20)
                            //设置3D旋转效果:沿y轴旋转10度(0 = 不参与旋转,1 = 沿该轴旋转)
                            .rotation3DEffect(
                                Angle(
                                    degrees: getPrecentage(geo: geometory) * 30
                                ),
                                axis: (x: 0.0, y: 1.0, z: 0.0)
                            )
                    }
                    .frame(width: 300, height: 150)
                    .padding()

                }
            }
        }
    }
    func getPrecentage(geo: GeometryProxy) -> Double {
        //得到屏幕中心
        let maxDistence = UIScreen.main.bounds.width / 2
        //得到此刻矩形的中间X坐标
        let currentX = geo.frame(in: .global).midX

        return 1 - (currentX / maxDistence)
    }
}

#Preview {
    GeometryReaderDemo()
}

预览中滑动:

在这里插入图片描述

在一个视图中呈现多个不同的工作表单sheet

在之前学习sheet的过程中,我们知道不能在sheet的闭包中使用条件逻辑。否则当战士第二个或第三个界面时,会出现错误。
现在就来讲这个确切的问题,并且解决方法。

情景如下:
在点击按钮1,按钮2后,并不会每次显示(有时候行)新视图的ONE和TWO,问题在于:
1.点击按钮,先赋值 seleModel = 新模型;
2.立刻执行 showSheet.toggle() 拉起 sheet 弹窗,此时会复用之前缓存的 NextSrceen 视图实例;
3.而子视图 NextSrceen 它内部是 let seleRandom 常量,只会保留第一次创建时的值,不会自动读取父视图最新 seleModel,所以偶尔显示旧标题。
简单说:sheet 弹出动画有延迟,NextSrceen视图提前缓存,内部不会响应父视图二次传参。

import SwiftUI

struct RandomModel: Identifiable{
    let id = UUID().uuidString
    let title: String
}

struct MuitipleSheetDemo: View {
    
    @State var seleModel: RandomModel = RandomModel(title: "起始标题")
    @State var showSheet: Bool = false
    
    var body: some View {
        VStack(spacing: 20){
            //点击这个,展示工作表,然后转到屏幕1
            Button("按钮1") {
                seleModel = RandomModel(title: "ONE")
                showSheet.toggle()
            }
            //点击这个,展示工作表,然后转到屏幕2
            Button("按钮2") {
                seleModel = RandomModel(title: "TWO")
                showSheet.toggle()
            }
        }
        .sheet(isPresented: $showSheet) {
            NextSrceen(seleRandom: seleModel)    
        }
    }
}

struct NextSrceen: View {
    
    let seleRandom: RandomModel
    
    var body: some View {
        Text(seleRandom.title)
            .font(.largeTitle)
    }
}

#Preview {
    MuitipleSheetDemo()
}

三种解决方法:

1.使用@Binding

只需修改下面的部分,其余不用改动:

在这里插入图片描述

2.使用多个工作表

但是要确保这些工作表不在同一层级容器上。
比如下面代码中,按钮1和按钮2时不同层级的容器,这两个工作表分别在各自上。

import SwiftUI

struct RandomModel: Identifiable{
    let id = UUID().uuidString
    let title: String
}

struct MuitipleSheetDemo: View {
    
    @State var seleModel: RandomModel = RandomModel(title: "起始标题")
    @State var showSheet: Bool = false
    @State var showSheet2: Bool = false
    
    var body: some View {
        VStack(spacing: 20){
            //点击这个,展示工作表,然后转到屏幕1
            Button("按钮1") {
                //seleModel = RandomModel(title: "ONE")
                showSheet.toggle()
            }
            .sheet(isPresented: $showSheet) {
                NextSrceen(seleRandom: RandomModel(title: "ONE"))
            }
            
            //点击这个,展示工作表,然后转到屏幕2
            Button("按钮2") {
                //seleModel = RandomModel(title: "TWO")
                showSheet.toggle()
            }
            .sheet(isPresented: $showSheet2) {
                NextSrceen(seleRandom: RandomModel(title: "TWO"))
            }
        }
//        .sheet(isPresented: $showSheet) {
//            
//            NextSrceen(seleRandom: seleModel)
//        }
    }
}

struct NextSrceen: View {
    
    let seleRandom: RandomModel
    
    var body: some View {
        Text(seleRandom.title)
            .font(.largeTitle)
    }
}

#Preview {
    MuitipleSheetDemo()
}

3.使用项目 $item 方式(推荐)

原理:每次给 seleModel 赋新实例,sheet 都会全新创建页面,永远拿到最新标题。

import SwiftUI

struct RandomModel: Identifiable{
    let id = UUID().uuidString
    let title: String
}

struct MuitipleSheetDemo: View {
    
    @State var seleModel: RandomModel?
    
    var body: some View {
        VStack(spacing: 20){
            //点击这个,展示工作表,然后转到屏幕1
            Button("按钮1") {
                seleModel = RandomModel(title: "ONE") 
            }
            
            //点击这个,展示工作表,然后转到屏幕2
            Button("按钮2") {
                seleModel = RandomModel(title: "TWO")  
            }
        }
        //使用item参数的sheet,绑定可识别的模型:
        .sheet(item: $seleModel) { model in
            NextSrceen(seleRandom: model)
        }
    }
}

struct NextSrceen: View {
    
    let seleRandom: RandomModel
    
    var body: some View {
        Text(seleRandom.title)
            .font(.largeTitle)
    }
}

#Preview {
    MuitipleSheetDemo()
}

遮罩修饰符 .mask

遮罩:有一个在底部的视图,有一个在顶部的视图,然后我们可以对顶部视图进行遮罩,以便使其形状与底部视图相同。

创建一个简单的快速评分组件,可以对事物进行一到五星评分。

学习要点:
1.允许点击测试修饰符.allowsHitTesting:用于设置该视图是否参与点击测试操作。
2.遮罩的使用

import SwiftUI

struct MaskDemo: View {

    @State var rating: Int = 0

    var body: some View {
        ZStack {
            startView
                .overlay(
                    overlayView
                        //把这个黄色矩形覆盖层作为遮罩
                        .mask(startView)
                )
        }
    }

    var overlayView: some View {
        //使用GeometryReader获取这个矩形的精确尺寸
        GeometryReader(content: { geometory in
            ZStack(alignment: .leading) {
                Rectangle()
                    .foregroundStyle(.yellow)
                    //根据评分改变矩形宽度
                    .frame(width: CGFloat(rating) / 5 * geometory.size.width)
            }
        })
        //允许点击测试修饰符:用于设置该视图是否参与点击测试操作。
        //这里设为false,说明用户没法点击覆盖层了,但仍能点击星星视图
        .allowsHitTesting(false)
    }

    var startView: some View {
        HStack {
            ForEach(1..<6) { index in
                Image(systemName: "star.fill")
                    .font(.largeTitle)
                    .foregroundStyle(.gray)
                    .onTapGesture {
                        withAnimation(.easeInOut) {
                            rating = index
                        }
                    }
            }

        }
    }
}

#Preview {
    MaskDemo()
}

在这里插入图片描述

添加音效

import SwiftUI
import AVKit

class SoundManager{
    //创建声音管理器单例,只要在这里初始化一次,每次想要使用声音管理器时,就通过这个实例访问,这样我们只需在应用程序创建一次它,而非每个屏幕都初始化
    static let instance = SoundManager()
    
    //创建一个音频播放器对象
    var player: AVAudioPlayer?
    
    func playSound(){
        
        //调用主包里的,带扩展名的资源都网址
        guard let url = Bundle.main.url(forResource: "tada", withExtension: ".mp3") else { return }
        do{
            player = try AVAudioPlayer(contentsOf: url)
            player?.play()
        }catch let error {
            print("error play sound. \(error.localizedDescription)")
        }
        
    }
}

struct AddSoundsDemo: View {
    var body: some View {
        VStack(spacing: 40){
            Button("播放音效1") {
                SoundManager.instance.playSound()
            }
            Button("播放音效2") {
                SoundManager.instance.playSound()
            }
        }
    }
}

#Preview {
    AddSoundsDemo()
}

添加触觉haptics / 振动vibrations

这个样例要在真机上测试,在模拟器/预览界面无法感受。

import SwiftUI

class HapticManager {

    //创建触觉管理器单例
    static let instance = HapticManager()

    func notification(type: UINotificationFeedbackGenerator.FeedbackType) {
        //用户界面通知反馈生成器
        let generator = UINotificationFeedbackGenerator()
        generator.notificationOccurred(type)
    }

    //用户界面碰撞反馈生成器
    func impact(style: UIImpactFeedbackGenerator.FeedbackStyle) {
        let generator = UIImpactFeedbackGenerator(style: style)
        generator.impactOccurred()
    }
}

struct AddhapticsAndvibrations: View {
    var body: some View {
        VStack(spacing: 30) {
            Button("success") {
                HapticManager.instance.notification(type: .success)
            }
            Button("warning") {
                HapticManager.instance.notification(type: .warning)
            }
            Button("error") {
                HapticManager.instance.notification(type: .error)
            }

            Divider()

            Button("soft") {
                HapticManager.instance.impact(style: .soft)
            }
            Button("light") {
                HapticManager.instance.impact(style: .light)
            }
            Button("medium") {
                HapticManager.instance.impact(style: .medium)
            }
            Button("rigid") {
                HapticManager.instance.impact(style: .rigid)
            }
            Button("heavy") {
                HapticManager.instance.impact(style: .heavy)
            }
        }
    }
}

#Preview {
    AddhapticsAndvibrations()
}

添加本地通知

本地通知与推送通知的区别:
我们可以在应用内自行设置本地通知,并在应用内自行创建。而推送通知来自于服务器。

设置本地通知的三个触发条件:
1.基于时间的:比如从现在起1小时后通知我。
2.基于日历的:比如可以设置每天下午5点的通知,或设置每周1安排通知。
3.基于位置的:当用户进入或离开某个地点时。

import SwiftUI
import UserNotifications
import CoreLocation

class NotificationManager{
    //设置通知管理器单例
    static let instance = NotificationManager()
    
    //在发送通知之前,要向用户请求权限,允许发送通知
    func requestAuthor() {
        //配置选项:要有弹窗,有声音,有徽章(用在图标上的数字)
        let options:UNAuthorizationOptions = [.alert, .sound, .badge]
        
        UNUserNotificationCenter.current().requestAuthorization(options: options) { (success, error) in
            if let error = error {
                print("错误: \(error)")
            }else{
                print("成功")
            }
        }
    }
    
    //设置具体通知
    func schedNotification() {
        
        let content = UNMutableNotificationContent()
        content.title = "我的第一条通知!"
        content.subtitle = "这太简单了!"
        content.sound = .default
        content.badge = 1
        
        //1.时间触发因素
        //此触发器将在:调度请求5秒后触发,不重复
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5.0, repeats: false)
        
        
        //2.日历触发因素
        //此触发器将在:每天11点15分触发,不重复
        var dateComponents = DateComponents()
        dateComponents.hour = 11
        dateComponents.minute = 14
        
        //let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
        
        
        //3.位置触发因素
        //此触发器将在:设备进入以经纬度为(40,50)为中心,半径为50m的地方触发,,离开时不触发,不重复
        let Coordinate = CLLocationCoordinate2D(
            latitude: 40.0,
            longitude: 50.0)
            
        let region = CLCircularRegion(
            center: Coordinate,
            radius: 50,
            identifier: UUID().uuidString)
        region.notifyOnEntry = true
        region.notifyOnExit = false
        
        //let trigger = UNLocationNotificationTrigger(region: region, repeats: false)
        
        
        //其中identifier:会被更多使用,若想跟踪此内容,比如先存起来,后面在看。但我们这里不跟踪任何内容,创建一个随机ID
        //触发因素trigger:要发送实际通知时的三种触发因素(时间,日历,地点)
        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: trigger)
        
        //添加请求
        UNUserNotificationCenter.current().add(request)
    }
    
    //取消通知
    func cancelNotification() {
        //移除所有待处理通知请求
        UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
        //移除所有已发送通知请求
        UNUserNotificationCenter.current().removeAllDeliveredNotifications()
    }
}

struct localNotificationDemo: View {
    var body: some View {
        VStack(spacing: 30){
            Button("请求权限") {
                //点击“允许”后请求授权会记住设备,第二次不需要。如果想重置,要删除该应用数据(删除APP)
                NotificationManager.instance.requestAuthor()
            }
            Button("安排通知") {
                NotificationManager.instance.schedNotification()
            }
            Button("取消通知") {
                NotificationManager.instance.cancelNotification()
            }
        }
        //取消角标数字
        .onAppear{
            UNUserNotificationCenter.current().setBadgeCount(0)
        }
    }
}

#Preview {
    localNotificationDemo()
}

在这里插入图片描述

在这里插入图片描述

获取请求@FetchRequest/核心数据Core Data

核心数据core data:简单的说,它是一个存储在iPhone里的数据库,可以用它来存数据,而且这些数据在不同会话间都能保留,如果用户关掉应用再重新打开,这些数据也会保存。
这有点类似于应用存储/用户默认设置,只不过核心数据更适合用来添加一整个数据库,存大量数据。

在使用Xcode创建项目时,勾选“Core Data”选项使用核心数据模版。
下面的代码实现:在输入框中输入水果名称,点击提交后,使用获取请求包装器@FetchRequest把它们保存到核心数据,所以要对原模版中的主要文件进行修改。

ContentView.swift文件:

import CoreData
import SwiftUI

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        entity: FruitEntity.entity(),
        sortDescriptors: []
    )
    private var fruits: FetchedResults<FruitEntity>
    
    @State var textfieldText: String = ""

    var body: some View {
        NavigationView {
            VStack {
                
                TextField("请输入....", text: $textfieldText)
                    .font(.headline)
                    .padding(.leading)
                    .frame(maxWidth: .infinity)
                    .frame(height: 55)
                    .background(Color.gray.opacity(0.3).cornerRadius(10))
                    .padding(.horizontal)
                
                Button("提交") {
                    addItem()
                }
                .font(.headline)
                .foregroundStyle(.white)
                .frame(maxWidth: .infinity)
                .frame(height: 55)
                .background(.blue)
                .cornerRadius(10)
                .padding(.horizontal)
                
                List {
                    ForEach(fruits) { fruit in
                        Text(fruit.name ?? "")
                    }
                    .onDelete(perform: deleteItems)
                }
            }

            .navigationTitle("水果")
            Text("Select an item")

        }
    }

    private func addItem() {
        withAnimation {
            let newFruit = FruitEntity(context: viewContext)
            newFruit.name = textfieldText
            saveItem()
            textfieldText = ""
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            guard let index = offsets.first else { return }
            let fruitEntity = fruits[index]
            viewContext.delete(fruitEntity)

            saveItem()
        }
    }

    private func saveItem() {
        do {
            try viewContext.save()
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
    }
}

private let itemFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateStyle = .short
    formatter.timeStyle = .medium
    return formatter
}()

#Preview {
    ContentView().environment(
        \.managedObjectContext,
        PersistenceController.preview.container.viewContext
    )
}

Persistence.swift

import CoreData

struct PersistenceController {
    static let shared = PersistenceController()

    @MainActor
    static let preview: PersistenceController = {
        let result = PersistenceController(inMemory: true)
        let viewContext = result.container.viewContext
        for x in 0..<10 {
            let newFruit = FruitEntity(context: viewContext)
            newFruit.name = "苹果\(x)"
        }
        do {
            try viewContext.save()
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
        return result
    }()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "CoreDataDemo")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
}

在模拟器上运行:

在这里插入图片描述

使用MVVM架构添加核心数据

MVVM架构:它代表模型(数据点),视图(用户界面),视图模型(管理视图的数据),大致的思路是把应用中的文件分开。

import Combine
import CoreData
import SwiftUI

class CoreDataViewModel: ObservableObject {

    let container: NSPersistentContainer
    @Published var saveEntities: [FruitEntity] = []

    init() {
    	//设置持久化容器
        container = NSPersistentContainer(name: "FruitsContainer")
        container.loadPersistentStores { (description, error) in
            if let error = error {
                print("加载核心数据失败. \(error) ")
            }
        }
        fetchFruits()
    }

    //从容器中获取数据
    func fetchFruits() {
        //创建获取请求
        let request = NSFetchRequest<FruitEntity>(entityName: "FruitEntity")
        do {
            //尝试从容器中获取数据
            saveEntities = try container.viewContext.fetch(request)
        } catch let error {
            print("Error fetching. \(error)")
        }
    }

    func addFruit(text: String) {
        let newFruit = FruitEntity(context: container.viewContext)
        newFruit.name = text
        
        saveData()
    }

    func deleteFruit(indexSet: IndexSet) {
        guard let index = indexSet.first else { return }
        let fruitEntity = saveEntities[index]
        container.viewContext.delete(fruitEntity)

        saveData()
    }
    //更新水果
    func updateFruit(entity: FruitEntity){
        let currentName = entity.name ?? ""
        let newName = currentName + "!"
        entity.name = newName
        
        saveData()
    }

    func saveData() {
        do {
            try container.viewContext.save()
            fetchFruits()
        } catch let error {
            print("Error saving. \(error)")
        }
    }

}

struct CoreDataDemo: View {

    @StateObject var vm = CoreDataViewModel()
    @State var textfieldText: String = ""

    var body: some View {
        NavigationView {
            VStack {
                TextField("请输入....", text: $textfieldText)
                    .font(.headline)
                    .padding(.leading)
                    .frame(maxWidth: .infinity)
                    .frame(height: 55)
                    .background(Color.gray.opacity(0.3).cornerRadius(10))
                    .padding(.horizontal)

                Button("提交") {
                    guard !textfieldText.isEmpty else { return }
                    vm.addFruit(text: textfieldText)
                    textfieldText = ""
                }
                .font(.headline)
                .foregroundStyle(.white)
                .frame(maxWidth: .infinity)
                .frame(height: 55)
                .background(.blue)
                .cornerRadius(10)
                .padding(.horizontal)

                List {
                    ForEach(vm.saveEntities) { entity in
                        Text(entity.name ?? "NO NAME")
                            .onTapGesture {
                                vm.updateFruit(entity: entity)
                            }
                    }
                    .onDelete(perform: vm.deleteFruit)
                }

            }

            .navigationTitle("水果")
        }
    }
}

#Preview {
    CoreDataDemo()
}

添加实体的文件:设置持久化容器时,容器名称一定要和文件名相同!

在这里插入图片描述

在模拟器的运行:

在这里插入图片描述

有关联的核心数据

第16节

后台线程threads/线索queues

注意:任何更新UI的操作,都需在主线程进行!

import Combine
import SwiftUI

class BackgroundThreadViewModel: ObservableObject {

    @Published var dataArray: [String] = []

    func fatcData() {
        //使用普通全局函数,把下载数据放在后台执行
        DispatchQueue.global().async {
            let tmp = self.downloadData()

            //每次更新数据数组时,会更新视图,此操作要切换为主线程
            DispatchQueue.main.async {
                self.dataArray = tmp
            }
        }

    }

    private func downloadData() -> [String] {
        var data: [String] = []

        for x in 1..<100 {
            data.append("\(x)")
            print(data)
        }
        return data
    }
}

struct BackgroundThreadDemo: View {

    @StateObject var vm = BackgroundThreadViewModel()

    var body: some View {
        ScrollView {
            VStack(spacing: 30) {
                Text("加载数据")
                    .font(.largeTitle)
                    .foregroundStyle(.black)
                    .onTapGesture {
                        vm.fatcData()
                    }

                ForEach(vm.dataArray, id: \.self) { data in
                    Text("\(data)")
                        .font(.title)
                        .foregroundStyle(.red)
                }
            }
        }

    }
}

#Preview {
    BackgroundThreadDemo()
}

在这里插入图片描述

在这里插入图片描述

weak self

import Combine
import SwiftUI

struct WeakSelfDemo: View {

    @AppStorage("count") var count: Int?

    init() {
        count = 0
    }

    var body: some View {
        NavigationStack {
            NavigationLink("导航", destination: myScreen())

                .navigationTitle("屏幕1")
        }
        .overlay(
            Text("\(count ?? 0)")
                .font(.largeTitle)
                .padding()
                .background(Color.green.cornerRadius(10)),
            alignment: .topTrailing
        )
    }
}

struct myScreen: View {

    @StateObject var vm = WeakSelfSecondScreenViewModel()

    var body: some View {
        Text("第二屏幕")
            .font(.largeTitle)
            .foregroundStyle(.red)

        if let data = vm.data {
            Text(data)
        }
    }
}

class WeakSelfSecondScreenViewModel: ObservableObject {

    @Published var data: String? = nil

    init() {
        print("INITIALIZE NOW")

        //这里用的是用户默认设置,因为不在视图中。
        //每次初始化时,计数+1
        let currentCount = UserDefaults.standard.value(forKey: "count")
        UserDefaults.standard.set(currentCount as! Int + 1, forKey: "count")

        getData()
    }
    deinit {
        print("DEINITIALIZE NOW")

        //每次销毁时,计数-1
        let currentCount = UserDefaults.standard.value(forKey: "count")
        UserDefaults.standard.set(currentCount as! Int - 1, forKey: "count")
    }
    func getData() {
        //此时获取数据的任务是立即发生的,所以进入时计数+1,返回时计数-1,最后计数一直都是1,这没问题。
        //data = "NEW DATA!!!!"

        //但使用全局异步调度队列时,我们用了self,这正在创建对这个类的强引用。
        //这告诉系统:在这个任务运行期间,这个self绝对需要保持存活。
        //但是如果我们从网上下载大量数据,这个异步中的代码可能要花几秒,甚至10秒,在这段时间里,用户可能在应用上做点什么,比如按下返回键,此时实际上就不需要这个实例self存活了,因为用户已经离开,已经在另一个屏幕了,不在需要那些数据。
        //        DispatchQueue.main.async {
        //            self.data = "NEW DATA!!!!"
        //        }
        //模拟一个长时间后台任务
        //        DispatchQueue.main.asyncAfter(deadline: .now() + 500) {
        //            self.data = "NEW DATA!!!!"
        //        }

        //解决方法:使用弱引用
        DispatchQueue.main.asyncAfter(deadline: .now() + 500) { [weak self] in
            self?.data = "NEW DATA!!!!"
        }
    }
}

#Preview {
    WeakSelfDemo()
}

在这里插入图片描述

使用弱引用后:

在这里插入图片描述

类型别名typealias

为现有类型创建新名称。

struct MovieModel{
    let title: String
    let director: String
    let count: Int
}

//把MovieModel重新命名为 TVModel
typealias TVModel = MovieModel

逃逸闭包@escaping

@escaping 逃逸闭包 = 把一个函数(闭包)当作另一个函数的参数,且这个闭包会在函数执行完毕之后才调用(跳出函数生命周期、“逃逸”)。

我们以前创建的函数几乎要要么没有返回值,要么只有一个常规返回类型,且基本能立即返回。
但当我们从网络连接数据库下载数据时,创建函数获取数据,但那些数据不会马上返回,要花几秒去服务器读取那些数据再带回我们的应用。所以我们不能直接从这个函数返回,需要处理这段异步代码,在swift里,我们用逃逸闭包实现。
所以基本上我们要做的是创建一个函数,然后在那个函数里,我们把另一个函数做为参数,传递给第一个函数,这样当我们的代码从数据库中返回以及希望他实际执行时就能调用第二个函数。

import Combine
import SwiftUI

class EscapingViewModel: ObservableObject {

    @Published var text: String = "Hello"

    func getData() {
        //        let newData = downloadData()
        //        text = newData

        //        downloadData3 { [weak self] data in
        //            self?.text = data
        //        }

        //        downloadData4 { [weak self] result in
        //            self?.text = result.data
        //        }

        downloadData5 { [weak self] result in
            self?.text = result.data
        }
    }
    //这是同步代码,返回值为常规类型,可立即返回
    func downloadData() -> String {
        return "New data!"
    }

    //先观察闭包结构:
    //1.这里的_表示,我们调用downloadData2时,不能给它的参数取外部名称了
    //2.这里参数里的函数返回的是空,也可以直接写为()
    func downloadData2(handler: (_ data: String) -> Void) {
        handler("New data!")
    }

    //模拟从网络数据库下载:
    //当添加@escaping进行转义时,就会让代码变成异步的,就不会立即执行并返回了。
    //这就是这里设置延时的原因
    func downloadData3(handler: @escaping (_ data: String) -> Void) {

        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            handler("New data!")
        }
    }

    //下面的两种写法使闭包代码更具可读性:
    //1.把闭包中的参数换成 结构体模型
    func downloadData4(handler: @escaping (DownloadResult) -> Void) {

        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            let result = DownloadResult(data: "New Data!")
            handler(result)
        }
    }
    //2.使用类型别名,把整个闭包重命名
    func downloadData5(handler: @escaping DownloadHandler) {

        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            let result = DownloadResult(data: "New Data!")
            handler(result)
        }
    }
}

struct DownloadResult {
    let data: String
}

typealias DownloadHandler = (DownloadResult) -> Void

struct EscapingDemo: View {

    @StateObject var vm = EscapingViewModel()

    var body: some View {
        Text(vm.text)
            .font(.largeTitle)
            .fontWeight(.semibold)
            .foregroundColor(.blue)
            .onTapGesture {
                vm.getData()
            }
    }
}

#Preview {
    EscapingDemo()
}

可编码协议

要仔细捋清楚下面注释代码的流程结构:

首先,在编码时需要使用字典手动的将模型数据转化为JSON格式数据,解码时也需手动把JSON对象转成字典,再分别提取字典中的字段。
然后,让模型同时遵循 Decodable和Encodable,在模型中编写用于解码的初始化器和用于编码的初始化器,就不需要手动的编码和解码了,直接调用JSON函数即可。
最后,直接让模型遵循 Codable,这样在模型中连用于解码的初始化器和用于编码的初始化器都不用写了,直接调用JSON函数即可。

import Combine
import SwiftUI

//Decodable 让客户模型符合可解码协议,可以让我们根据某种数据初始化一个客户模型
//Encodable 可以让模型编码为数据
//但是可编码协议Codable = Decodable + Encodable,所以只遵循Codable即可!
struct CustomerModel: Identifiable, Codable {
    let id: String
    let name: String
    let points: Int
    let isPremium: Bool

    //Codable的强大之处是:它把下面的编码键枚举和三种初始化器全部自动生成了!!
    //编码键
    //    enum CodingKeys: String, CodingKey {
    //        case id
    //        case name
    //        case points
    //        case isPremium
    //    }
    //
    //    init(id: String, name: String, points: Int, isPremium: Bool) {
    //        self.id = id
    //        self.name = name
    //        self.points = points
    //        self.isPremium = isPremium
    //    }
    //
    //    //用于解码的初始化器
    //    init(from decoder: Decoder) throws {
    //        let container = try decoder.container(keyedBy: CodingKeys.self)
    //        self.id = try container.decode(String.self, forKey: .id)
    //        self.name = try container.decode(String.self, forKey: .name)
    //        self.points = try container.decode(Int.self, forKey: .points)
    //        self.isPremium = try container.decode(Bool.self, forKey: .isPremium)
    //    }
    //
    //    //用于编码的初始化器
    //    func encode(to encoder: Encoder) throws {
    //        var container = encoder.container(keyedBy: CodingKeys.self)
    //        try container.encode(id, forKey: .id)
    //        try container.encode(name, forKey: .name)
    //        try container.encode(points, forKey: .points)
    //        try container.encode(isPremium, forKey: .isPremium)
    //    }

}

class CodableViewModel: ObservableObject {

    @Published var customer: CustomerModel? = nil

    init() {
        getData()
    }

    func getData() {

        guard let data = getJSONData() else { return }

        //用于解码的初始化器后,就不用手动解码了
        self.customer = try? JSONDecoder().decode(
            CustomerModel.self,
            from: data
        )

        //从下面的打印看出:JSON格式的数据并不是我们想要的,我们无法使用
        //print("JSON DATA: ")
        //print(data) //打印结果:55 bytes

        //let jsonString = String(data: data, encoding: .utf8)
        //print (jsonString) //打印结果://打印结果:Optional("{\"points\":5,\"name\":\"Joe\",\"isPremium\":true,\"id\":\"12345\"}")

        //把JSON对象转成字典,要手动分别提取字典中的字段
        //        guard let localData = try? JSONSerialization.jsonObject(with: data, options: []) else { return }
        //
        //        guard let dictionary = localData as? [String:Any] else { return }
        //
        //        guard let id = dictionary["id"] as? String,
        //              let name = dictionary["name"] as? String,
        //              let points = dictionary["points"] as? Int,
        //              let isPremium = dictionary["isPremium"] as? Bool else { return }
        //
        //        let newCustomer = CustomerModel(id: id, name: name, points: points, isPremium: isPremium)
        //            customer = newCustomer

    }

    func getJSONData() -> Data? {

        let customer = CustomerModel(
            id: "111",
            name: "zhangsan",
            points: 100,
            isPremium: true
        )

        //有了编码初始化器后,就不用手动创建字典了,可以直接用模型,直接就变成JSON格式数据了
        let jsonData = try? JSONEncoder().encode(customer)

        //        let dictionary: [String: Any] = [
        //            "id": "12345",
        //            "name": "Joe",
        //            "points": 5,
        //            "isPremium": true,
        //        ]
        //        //使用JSON序列化专成JSON格式数据
        //        let jsonData = try? JSONSerialization.data(
        //            withJSONObject: dictionary,
        //            options: []
        //        )
        return jsonData
    }
}

struct CodableDemo: View {

    @StateObject var vm = CodableViewModel()

    var body: some View {
        VStack(spacing: 20) {
            if let customer = vm.customer {
                Text(customer.id)
                Text(customer.name)
                Text("\(customer.points)")
                Text(customer.isPremium.description)
            }
        }
    }
}

#Preview {
    CodableDemo()
}

从网络中下载数据

1.用逃逸闭包

import SwiftUI
import Combine

struct PostModel: Identifiable, Codable{
    let userld: Int
    let id: Int
    let title: String
    let body: String
}

class DownloadWithEscapingViewModel: ObservableObject {
    
    @Published var posts: [PostModel] = []
    
    init() {
        // 初始化时自动请求数据
        getPosts()
    }
    
    func getPosts() {
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }
        
        downloadData(fromURL: url) { returnedData in
            
            if let data = returnedData {
                //使用可编码协议进行解码
                guard let newPosts = try? JSONDecoder().decode ([PostModel].self, from: data) else { return }
                
                DispatchQueue.main.async { [weak self] in
                    self?.posts = newPosts
                }
            } else {
                print("无数据返回!")
            }
        }
    }
    //提取出通用下载方法,传入任意url即可下载数据
    func downloadData(fromURL url: URL, handler: @escaping(_ data: Data?) -> ()){
        //dataTask会自动进入后台线程
        URLSession.shared.dataTask(with: url) { (data, response, error) in
            guard
                let Data = data,
                  error == nil,
                  let response = response as? HTTPURLResponse,
                  response.statusCode >= 200 && response.statusCode < 300 else {
                print("Error downloading data. ")
                handler(nil)
                return
            }
            
            handler(Data)
            
        }.resume ()
    }
}


struct DownloadWithEscaping: View {
    
    @StateObject var vm = DownloadWithEscapingViewModel()
    
    var body: some View {
        List {
            ForEach(vm.posts) { post in
                VStack {
                    Text(post.title)
                        .font(.headline)
                    Text (post.body)
                        .foregroundColor(.gray)
                }
            }
        }
    }
}

#Preview {
    DownloadWithEscaping()
}

2.用Combine框架

Combine:是一种通过组合事件处理运算符的处理异步事件的方式。
我们现在主要关注的是发布者Publisher和订阅者Subscriber。发布者本质上是一个会随时间给我们传递一些值的对象,所以我们能创建发布者,接着我们就能订阅发布者,每当这个发布者生成数据,它就会发布数据,我们就能使用它。

用现实生活中按月订阅包裹的事件流程进行类比:
1.注册后按月订阅一个会被送达的包裹

1.创建发布者

2.公司在幕后制作包裹

2.在后台线程生订阅发布者(在数据任务发布者中是默认执行的,这里写只是走个过场)

3.公司寄出后,你会在门口收到包裹

3.在主线程上接收

4.确保包裹没有损坏

4.尝试映射(检查数据是否有问题)

5.打开包裹并确认里面的东西是否正确

5.解码(把数据解码成文字模型)

6.使用这个东西!!

6.同步操作sink(会把条目放进应用里,即在应用里使用)

7.这个套餐订阅随时能取消!!!

7.使用store,将取消发布者变量存在取消列表里(如有需要随时取消订阅)

import Combine
import SwiftUI

struct PostModel: Identifiable, Codable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

class DownloadWithCombineViewModel: ObservableObject {

    @Published var posts: [PostModel] = []
    
    //这是用来存储发布者的变量,如果将来想取消,访问这个变量取消
    var cancellables = Set<AnyCancellable>()

    init() {
        getPosts()
    }

    func getPosts() {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }
            
        //1.创建发布者
        URLSession.shared.dataTaskPublisher(for: url)
            //2.在后台线程生订阅发布者(在数据任务发布者中是默认执行的,这里写只是走个过场)
            .subscribe(on: DispatchQueue.global(qos: .background))
            //3.在主线程上接收
            .receive(on: DispatchQueue.main)
            //4.尝试映射(检查数据是否有问题)
            .tryMap(handOutput)
            //5.解码(把数据解码成文字模型)
            .decode(type: [PostModel].self, decoder: JSONDecoder())
            //6.同步操作sink(会把条目放进应用里,即在应用里使用)
            .sink { completion in
                print("COMPLETION: \(completion)")
            } receiveValue: { [weak self] returnedPosts in
                self?.posts = returnedPosts
            }
            //7.使用store,将取消发布者变量存在取消列表里(如有需要随时取消订阅)
            .store(in: &cancellables)
    }
    
    func handOutput(output: URLSession.DataTaskPublisher.Output) throws -> Data{
        guard
            let response = output.response as? HTTPURLResponse,
              response.statusCode >= 200 && response.statusCode < 300 else {
            throw URLError(.badServerResponse)
        }
        return output.data
    }
}

struct DownloadWithCombine: View {

    @StateObject var vm = DownloadWithCombineViewModel()

    var body: some View {
        List {
            ForEach(vm.posts) { post in
                VStack(alignment: .leading) {
                    Text(post.title)
                        .font(.headline)
                    Text(post.body)
                        .foregroundColor(.gray)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
    }
}

#Preview {
    DownloadWithCombine()
}

定时器Timer和.onReceive

下面的代码中举了四个例子来帮助大家理解定时器Timer和.onReceive:
1.时间显示器
2.倒计时器
3.倒计时到某个日期
4.自动轮播页面

import SwiftUI
import Combine

struct TimerDemo: View {
    
    //在屏幕上创建一个定时器:每秒发布,运行在主线程,普通模式
    let timer = Timer.publish(every: 3.0, on: .main, in: .common).autoconnect()
    
    @State var currentDate: Date  = Date()
    
    //设置当前时间格式样式
    /*
    var dateFormatter: DateFormatter{
        let formatter = DateFormatter()
        //formatter.dateStyle = .medium
        formatter.timeStyle = .medium
        return formatter
    }
     */
    
    //倒计时
    /*
    @State var count:Int = 10
    @State var finishedText: String? = nil
     */
    
    //倒计时到日期
    /*
    @State var timeRemaining: String = ""
    //当前的时间+一天
    let futureDate: Date = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date()
    
    func updateTimeRemaining() {
        //得到从当前时间到未来时间剩余的时分秒
        let remaining = Calendar.current.dateComponents([.hour, .minute, .second], from: Date(), to: futureDate)
        
        let hour = remaining.hour ?? 0
        let minute = remaining.minute ?? 0
        let second = remaining.second ?? 0
        
        timeRemaining = "\(hour):\(minute):\(second)"
    }
     */
    
    //动画效果 - 页面自动轮播
    @State var count: Int = 0
    
    var body: some View {
        ZStack {
            RadialGradient(
                colors: [Color.purple, Color.teal],
                center: .center,
                startRadius: 5,
                endRadius: 500)
            .ignoresSafeArea(.all)
            
            //Text(dateFormatter.string(from: currentDate))
            //Text(finishedText ?? "\(count)")
//            Text(timeRemaining)
//                .font(.system(size: 100, weight: .semibold, design: .rounded))
//                .foregroundStyle(.white)
//                .lineLimit(1) //限制单行
//                .minimumScaleFactor(0.1)//放不下自动缩小字号,文字不会截断省略
            
            TabView(selection: $count) {
                Rectangle()
                    .foregroundStyle(.red)
                    .tag(1)
                Rectangle()
                    .foregroundStyle(.blue)
                    .tag(2)
                Rectangle()
                    .foregroundStyle(.green)
                    .tag(3)
                Rectangle()
                    .foregroundStyle(.gray)
                    .tag(4)
                Rectangle()
                    .foregroundStyle(.orange)
                    .tag(5)
            }
            .frame(height: 200)
            .tabViewStyle(.page)
        }
        
        //这里是在订阅发布者timer(监听这个定时器),每次它发布一个值时,我们就执行一个操作
        //value的值取决于该发布者所发布的类型
//        .onReceive(timer) { value in
//            currentDate = value
//        }
        
//        .onReceive(timer) { _ in
//            if count <= 1 {
//                finishedText = "Wow!!"
//            }else{
//                count -= 1
//            }
//        }
        
//        .onReceive(timer) { _ in
//            
//            updateTimeRemaining()
//        }
        
        .onReceive(timer) { _ in
            withAnimation(.default) {
                count = count == 5 ? 0 : count + 1
            }
        }
    }
}

#Preview {
    TimerDemo()
}

文件管理器

import Combine
import SwiftUI

//这个本地文件管理器类是通用的
class LocalFileManager {

    static let instance = LocalFileManager()
    let folderName = "MyApp_Images"

    init() {
        createForlder()
    }

    func createForlder() {
        //创建文件夹路径
        guard
            let path = FileManager
                .default
                .urls(for: .cachesDirectory, in: .userDomainMask)
                .first?
                .appendingPathComponent(folderName)
                .path()
        else {
            print("获取路径失败!")
            return
        }

        //检查这个文件路径是否存在,不存在就创建新目录
        if !FileManager.default.fileExists(atPath: path) {
            do {
                try FileManager.default.createDirectory(
                    atPath: path,
                    withIntermediateDirectories: true,
                    attributes: nil
                )
                print("创建文件夹成功!")
            } catch let error {
                print("创建文件夹错误! \(error)")
            }
        }
    }
    
    //删除文件夹
    func deleteFolder() {
        guard
            let path = FileManager
                .default
                .urls(for: .cachesDirectory, in: .userDomainMask)
                .first?
                .appendingPathComponent(folderName)
                .path()
        else {
            print("获取路径失败!")
            return
        }
        
        do {
            try FileManager.default.removeItem(atPath: path)
            print("删除文件夹成功!")
        } catch let error{
            print("删除文件夹失败! \(error)" )
            return
        }
    }

    func saveImage(image: UIImage, name: String) {

        //把图像保存到文件管理器时,不是直接保存图像,而是图像数据.
        //这是是.jpg格式图片,参数:压缩质量。
        guard
            let data = image.jpegData(compressionQuality: 1.0),
            let path = getPathForImage(name: name)
        else {
            print("获取图像数据失败!")
            return
        }

        //参数:保存到的文件目录,使用的掩码,返回值是一个数组
        //文档目录.documentDirectory:存放仅用户生成的文档和其他数据,或无法由应用程序重建的内容
        //缓存目录.cachesDirectory:可再次下载或可重新生成的数据
        //临时目录FileManager.default.temporaryDirectory:临时使用的数据或是不太重要的内容
        //let director = FileManager.default.urls(for: .documentDirectory ,in: .userDomainMask)
        //let director = FileManager.default.urls(for: .cachesDirectory ,in: .userDomainMask).first
        //添加扩展名
        //let path = director?.appendingPathComponent("\(name).jpg")

        //把数据写入到
        do {
            try data.write(to: path)
            print(path)
            print("保存图片成功!")
        } catch let error {
            print("保存图片错误! \(error)")
        }
    }

    func getImage(name: String) -> UIImage? {
        guard
            //获取到路径且文件存在
            let path = getPathForImage(name: name)?.path(),
            FileManager.default.fileExists(atPath: path)
        else {
            print("获取路径失败!")
            return nil
        }

        return UIImage(contentsOfFile: path)
    }

    func deleteImage(name: String) {
        guard
            //获取到路径且文件存在
            let path = getPathForImage(name: name),
            FileManager.default.fileExists(atPath: path.path())
        else {
            print("获取路径失败!")
            return
        }

        do {
            try FileManager.default.removeItem(at: path)
            print("删除图片成功!")
        } catch let error {
            print("删除图片失败! \(error)")
        }
    }

    func getPathForImage(name: String) -> URL? {
        guard
            let path = FileManager
                .default
                .urls(for: .cachesDirectory, in: .userDomainMask)
                .first?
                .appendingPathComponent(folderName)
                .appendingPathComponent("\(name).jpg")
        else {
            print("获取路径失败!")
            return nil
        }
        return path
    }
}

class FileManagerViewModel: ObservableObject {

    @Published var image: UIImage? = nil
    let imageName: String = "p3"
    let manager = LocalFileManager.instance

    init() {
        getImageFromAssetsFolder()
    }

    func getImageFromAssetsFolder() {
        image = UIImage(named: imageName)
    }

    func getImageFromFileManager() {
        image = manager.getImage(name: imageName)
    }

    func saveImage() {
        guard let image = image else { return }
        manager.saveImage(image: image, name: imageName)
    }

    func deleteImage() {
        manager.deleteImage(name: imageName)
        manager.deleteFolder()
    }
}

struct FileManagerDemo: View {

    @StateObject var vm = FileManagerViewModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                if let image = vm.image {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFill()
                        .frame(width: 200, height: 200)
                        .clipped()
                        .cornerRadius(20)
                }

                HStack {
                    //模拟把从网络上下载的图片保存到文件管理器
                    Button {
                        vm.saveImage()
                    } label: {
                        Text("保存到FM")
                            .font(.headline)
                            .foregroundStyle(.white)
                            .padding()
                            .padding(.horizontal)
                            .background(.blue)
                            .cornerRadius(10)
                    }

                    Button {
                        vm.deleteImage()
                    } label: {
                        Text("从FM里删除")
                            .font(.headline)
                            .foregroundStyle(.white)
                            .padding()
                            .padding(.horizontal)
                            .background(.red)
                            .cornerRadius(10)
                    }
                }

                Spacer()
            }
            .navigationTitle("文件管理器")
        }
    }
}

#Preview {
    FileManagerDemo()
}

在这里插入图片描述

缓存NSCache

import Combine
import SwiftUI

class CacheManager{
    
    static let instance = CacheManager()
    private init() {}
    
    //创建全局缓存实例
    var imageCache: NSCache<NSString, UIImage> = {
        let cache = NSCache<NSString, UIImage>()
        //最多同时缓存100 个图片条目,超出时自动清理
        cache.countLimit = 100
        //缓存总占用上限为100MB,超出时自动清理
        cache.totalCostLimit = 1024 * 1024 * 100
        
        return cache
    }()
    
    //存图
    func add(image: UIImage, name:String){
        imageCache.setObject(image, forKey: name as NSString)
        print("存图进缓存成功!")
    }
    
    //移除图
    func remove(name: String) {
        imageCache.removeObject(forKey: name as NSString)
        print("从缓存中移除图成功!")
    }
    
    //保存到缓存后获取图片
    func get(name: String) -> UIImage? {
        return imageCache.object(forKey: name as NSString)
    }
}

class CacheViewModel: ObservableObject {

    @Published var startingImage: UIImage? = nil
    @Published var cacheImage: UIImage? = nil
    
    let imageName: String = "p3"
    let manager = CacheManager.instance

    init() {
        getImageFromAssetsFolder()
    }

    func getImageFromAssetsFolder() {
        startingImage = UIImage(named: imageName)
    }
    
    func saveToCache() {
        guard let image = startingImage else { return }
        manager.add(image: image, name: imageName)
    }
    
    func removeFromCache() {
        manager.remove(name: imageName)
    }
    
    func getFromCache() {
        cacheImage = manager.get(name: imageName)
    }
}

struct CacheDemo: View {

    @StateObject var vm = CacheViewModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                if let image = vm.startingImage {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFill()
                        .frame(width: 200, height: 200)
                        .clipped()
                        .cornerRadius(20)
                }

                HStack {
                    Button {
                        vm.saveToCache()
                    } label: {
                        Text("保存到缓存")
                            .font(.headline)
                            .foregroundStyle(.white)
                            .padding()
                            .padding(.horizontal)
                            .background(.blue)
                            .cornerRadius(10)
                    }

                    Button {
                        vm.removeFromCache()
                    } label: {
                        Text("从缓存删移除")
                            .font(.headline)
                            .foregroundStyle(.white)
                            .padding()
                            .padding(.horizontal)
                            .background(.red)
                            .cornerRadius(10)
                    }
                }
                
                Button {
                    vm.getFromCache()
                } label: {
                    Text("从缓存获取")
                        .font(.headline)
                        .foregroundStyle(.white)
                        .padding()
                        .padding(.horizontal)
                        .background(.blue)
                        .cornerRadius(10)
                }
                
                if let image = vm.cacheImage {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFill()
                        .frame(width: 200, height: 200)
                        .clipped()
                        .cornerRadius(20)
                }

                Spacer()
            }
            .navigationTitle("缓存")
        }
    }
}

#Preview {
    CacheDemo()
}

小总结实例

第28节

可访问性

1.文本的可访问性

无论用户设置的文本大小如何,屏幕尺寸如何,要让屏幕上的所有文字都极易阅读,并且要确保你的用户界面能适应和支持这点,即动态文本。

import SwiftUI

//尽量避免使用固定尺寸
//添加行限制
//添加最小缩放比

struct AccessibilityTextDemo: View {

    @Environment(\.sizeCategory) var sizeCategory

    var body: some View {
        NavigationStack {
            List {
                ForEach(1..<10) { _ in
                    VStack(alignment: .leading, spacing: 8) {
                        HStack {
                            Image(systemName: "heart.fill")
                            Text("欢迎来到我的应用!")
                        }
                        .font(.title)

                        Text("这是一段较长的文本,我们在学习中,会自动换行成多行!!")
                            .font(.subheadline)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .lineLimit(3)
                            .minimumScaleFactor(
                                sizeCategory.customMinScaleFactor
                            )
                    }
                    //如果这样设置固定尺寸,当iphone的屏幕比较小时(如iphone8,iphone9等机型),
                    //文字就会显示不完整,会出现省略号
                    //.frame(height: 100)
                    .background(Color.red)
                }
            }
            .listStyle(.plain)
            .navigationTitle("动态文本")
        }
    }
}

extension ContentSizeCategory {

    var customMinScaleFactor: CGFloat {
        switch self {
        case .extraSmall, .small, .medium:
            return 1.0
        case .large, .extraLarge, .extraExtraLarge:
            return 0.8
        default:
            return 0.6
        }
    }
}

#Preview {
    AccessibilityTextDemo()
}

2. 颜色的可访问性

第30节

3.画外音Voice Over

第31节

视觉效果视图修饰符

它是一种类似使用几何读取器的修饰符。

import SwiftUI

struct VisualEffectViewFemo: View {
    var body: some View {
        //下面代码:视觉效果修饰符的简单使用
//        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
//            .padding()
//            .frame(width: 100)
//            .background(.red)
            //添加视觉效果获取该文本的几何信息。
            //这里的content并非只是一个视图,是一种特殊的视觉效果类型,
            //它能使用各种视觉效果:模糊,亮度,色彩,对比度,扭曲,灰度,色相旋转,偏移,不透明度,旋转,3D旋转,缩放等。
            //但是它也有局限性,它不能使用固定边框,也无法使用前景色。
            //这里的geometry中,包含里该文本的几何信息。
//            .visualEffect { content, geometry in
//                content
//                    .grayscale(geometry.size.width >= 200 ? 1 : 0)
//            }
            
        
        //下面代码:实现矩形的 视差滚动 效果
        ScrollView{
            VStack(spacing: 30){
                ForEach(1..<100) { index in
                    Rectangle()
                        .frame(width: 300, height: 200)
                        .frame(maxWidth: .infinity)
                        .background(Color.orange)
                        .visualEffect { content, geometry in
                            content
                                //橙色方块在屏幕里的纵向位置(minY)增加,方块会同步沿着 X 轴向右产生偏移
                                .offset(x: geometry.frame(in: .global).minY * 0.5)
                        }
                }
            }
        }
    }
}

#Preview {
    VisualEffectViewFemo()
}

在这里插入图片描述

滚动视图上的分页功能

iOS17新增

import SwiftUI

struct ScrollViewPagingDemo: View {
    
    @State private var scrollPostions: Int? = nil
    
    var body: some View {
        //下面代码:滚动视图分页的基础演示
//        ScrollView{
//            VStack(){
//                ForEach(1..<15) { index in
//                    Rectangle()
//                        //.frame(width: 300, height: 300)
//                        .overlay {
//                            Text("\(index)")
//                                .foregroundStyle(.white)
//                        }
//                        .frame(maxWidth: .infinity)
//                        .padding(.vertical, 10)
//                        .containerRelativeFrame(.vertical, alignment: .center)
//                }
//            }
//        }
        //.ignoresSafeArea()
        //2.标记布局容器为滚动锚点,与scrollTargetBehavior搭配使用
        //.scrollTargetLayout()
        //3.设置视图目标行为:视图对齐,分页。
        //其中 视图对齐:滑动松开是会自动过渡到与框架顶部对齐处
        //    分页:在滑动时类似抖音的信息流,每次向上滑动显示一页。一般与 与容器相关框架containerRelativeFrame 搭配。
        //.scrollTargetBehavior(.paging)
        //1.设置滚动视图回弹行为:始终,自动(默认),按尺寸
        //其中 按尺寸:比如只有一两个单元格,尺寸没有占满屏幕,就不会发生回弹效果,否则就回弹。
        //.scrollBounceBehavior(.basedOnSize)
        
        
        
        VStack {
            
            Button("滚动到") {
                scrollPostions = (0..<20).randomElement()!
            }
            
            ScrollView(.horizontal){
                HStack(spacing: 0){
                    ForEach(1..<20) { index in
                        Rectangle()
                            .frame(width: 300, height: 300)
                            .cornerRadius(10)
                            .overlay {
                                Text("\(index)")
                                    .foregroundStyle(.white)
                            }
                            .frame(maxWidth: .infinity)
                            .padding(10)
                            .id(index)
                        //4.设置滚动过渡:
                        //交互式(默认):滚动时每个像素都改变不透明度
                        //基准样式:完全不做动画,保持原样
                        //动画式:在某一点改变不透明度,不是每个像素渐变
                        //其中 phase告诉我们单元格是否在屏幕上
                            .scrollTransition(.interactive) { content, phase in
                                content
                                    .opacity(phase.isIdentity ? 1 : 0)
                            }
                        //.containerRelativeFrame(.horizontal, alignment: .center)
                    }
                }
            }
            .ignoresSafeArea()
            .scrollTargetLayout()
            .scrollTargetBehavior(.paging)
            .scrollBounceBehavior(.basedOnSize)
            //设置滚动到某个位置
            .scrollPosition(id: $scrollPostions, anchor: .center)
            .animation(.smooth, value: scrollPostions)
        }
    }
}

#Preview {
    ScrollViewPagingDemo()
}

在这里插入图片描述

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐