75142913在线留言
CS193p2021学习笔记第十课:Multithreading Demo Gestures 多线程演示与手势_IOS开发_网络人

CS193p2021学习笔记第十课:Multithreading Demo Gestures 多线程演示与手势

Kwok 发表于:2021-08-29 09:29:47 点击:2 评论: 0

接上一节课理论问题,本课将针对多线程应用部分演示,通过从safari浏览器拖拽图片到画布并下载到本地。

一、将来自safari浏览器的图片设置为背影

上一课的代码里我们在接受放下类型里已加入了.url与.image,所以我们可以尝试从safari拖拽进入,下面需要对进入的数据进行处理能才正确的使用,我们重写func drop增加对URL与imageData的数据处理:

1、修改放下函数

//当被接受的内容[.plainText,.url,.image]放下后执行的函数
private func drop(providers: [NSItemProvider], at location: CGPoint, in geometry: GeometryProxy) -> Bool {
    //先从URL检测开始并尝试放下然后抓取远程图片并设置为背影
    var found = providers.loadObjects(ofType: URL.self) { url in
        //url.imageURL来算扩展extension URL,将检测URL是否正确
        document.setBackground(.url(url.imageURL))//VM里的设置背影
    }
    //当found返回false时尝试放下图片
    if !found {
        found = providers.loadObjects(ofType: UIImage.self) { image in
            //尝试将image返回包含指定JPEG格式图像的数据对象。
            if let data = image.jpegData(compressionQuality: 1.0) {
                document.setBackground(.imageData(data))//imageData枚举值
            }
        }
    }
    //最后尝试放下Emoji,这也是上一课已实现的功能
    if !found {
        found = providers.loadObjects(ofType: String.self) { string in
            //isEmoji通过算法判断是否为Emoji
            if let emoji = string.first, emoji.isEmoji {
                //调用VM里的addEmoji
                document.addEmoji(
                    String(emoji),
                    at: convertToEmojiCoordinates(location, in: geometry),
                    size: defaultEmojiFontSize
                )
            }
        }
    }
    return found
}

 2、尝试在画板上显示背景

原来的背影是Color.yellow,我们需要将这代码替换成下面的:

Color.white.overlay(
    //使用背影图片覆盖,图片来源VM
    Image(uiImage: document.backgroundImage)
        //将背影图片定位到画布中间
        .position(convertFromEmojiCoordinates(0,0, in: geometry))
)

我们通过使用一个UIImage覆盖白色背影的方式来实现背景的设置,但是URL远程下载的方法我们还没有写。

3、定义document.backgroundImage: 

@Published var backgroundImage: UIImage?//定义背影图片

因为图片可能不存在,所以我们只能定义成Optional类型,但是Image不能直接使用Optional所以我们对其进行了修改为OptionalImage(来源于上一课的UtilityViews.swift)

4、触发.bakgroundImage更新

上面我们只是定义了 bakgroundImage 但要更新其值,但需要与Model的数据产生联动,所以我们为emojiArt设置一个观察者属性更新bakgroundImage的值:

@Published private(set) var emojiArt: EmojiArtModel {
    didSet {
        //如果background的值产生了变化则执行抓取任务
        if emojiArt.background != oldValue.background {
            fetchBackgroundImageDataIfNecessary()
        }
    }
}

上面我们使用了 mojiArt.background != oldValue.background 来判断两个背景值是否相等,会报错:Binary operator '!=' cannot be applied to two 'EmojiArtModel.Background' operands,我们只需要在枚举里增加:Equatable协议即可,swift会自动完成==与!=的工作。

5、实现 fetchBackgroundImageDataIfNecessary() 远程抓取图片(14:00):

//定义一下远程图片背景的抓取状态,默认为.idle
//其目的是为了让用户在视图上知道现在我们正在抓取内容
@Published var backgroundImageFetchStatus = BackgroundImageFetchStatus.idle
//定义状态的枚举选项
enum BackgroundImageFetchStatus {
    case idle
    case fetching
}
//实际远程抓取函数
private func fetchBackgroundImageDataIfNecessary() {
    backgroundImage = nil//假如上一次抓取还未完成,则立即初始化重新来过
    switch emojiArt.background {
        // 检测到是URL开始尝试抓取工作
        case .url(let url):
            backgroundImageFetchStatus = .fetching //将状态设置为抓取中
            //使用后台队列的userInitiated(上一课结尾理论有讲)优先等级异步处理
            DispatchQueue.global(qos: .userInitiated).async {
                let imageData = try? Data(contentsOf: url)//使用Data完成远程下载工作(此步耗时)
                DispatchQueue.main.async { [weak self] in //当抓取完成后切换到主队列异步
                    //判断被下载的地址与用户最新发新的地址是否一致(用户可能会放很多进来)
                    if self?.emojiArt.background == EmojiArtModel.Background.url(url) {
                        self?.backgroundImageFetchStatus = .idle//更新抓取状态(弱引用)
                        //上面使用了try?说明imageData是有可能失败的
                        if imageData != nil {
                            //图片抓取成功后将其保存到backgroundImage变量(使用UIImage转换数据)
                            self?.backgroundImage = UIImage(data: imageData!)//self?为弱引用
                        }
                    }
                }
            }
            //检测到是Data数据
        case .imageData(let data):
            backgroundImage = UIImage(data: data)
            //未设置背影的情况
        case .blank:
            break
    }
}

此步代码虽多,但是上一节里的理论部分已详细对后台队列和主队列进行讲解,这里的知识点 [weak self] 的理解(视频21:40处)。weak self是一个弱引用,其目的为了使被执行完后的闭包引用计数清0以达到内存回收的目的。self?.backgroundImage里的self变成了Optional类型,如果没有人将其保存到堆里的时候,则会变成nil值被系统自动回收。

 6、显示图片下载信息(提升用户体验)

在上面的ViewModel里我们使用了backgroundImageFetchStatus获取到了图片抓取状态。通过判断当前状态我们可以在视频里做一些提供用户体验的事情:

//backgroundImageFetchStatus 是一个 @Published var 
if document.backgroundImageFetchStatus == .fetching{
    ProgressView().scaleEffect(2)
} else{ ... }

二、Gestures 手势理论知识(30:01)

手势都是关于用户触控系统获取到的输入信息。swift拥有强大的系统监控用户手指做出的手势。我们的能做的就是处理用户的手势输入后需要完成的事情。

1、在视图里应用手势

MyView.gesture(theGesture)//组合手势需要定义成theGesture通过.gesture附加在视图上

2、创建离散手势

//some Gesture可以返回组合手势
var theGesture: some Gesture{
    return TapGesture(count:2)//双击手势
         .onEnded { /* 监听到用户双击后要完成的事务 */ }
}

上面创建了手势监听2次点击,监听到已后我们就要运行用户动作对应的代码。这样的手势动作定义起来非常简单。系统已自带了这些手势:

MyView.onTapGesture(count: Int) { /* 双击后要执行的代码 */ }
MyView.onLongPressGesture(...) { /* 长按后要执行的代码 */ }

所以基本上也不会去定义这类离散手势。我们更多时候使用的是非离散手势的定义。

3、非离散手势

在此手势中,不仅要处理被手势识别并结束的事务(捏合结束了),也可能想要在捏全过程中数据产生的一些变化,如根据捏调整大小、拖动离开位置、旋转等。上面写到的LongPressGesture可以是离散的,也可以说是非离散的,当按下时可以执行一些动画或者其它操作,松开已经也可以处理其它结果(长按点赞数量一直增加,松开就停止)。

在非离散手势的.onEnded里可以获取到value in的值:

var theGesture: some Gesture{
     DragGesture( ... )
         .onEnded { value in /* 在执行的代码中可以访问 value */}
}

其value值来源于当前所监听的手势。参考下面的分类:

a. DragGesture 拖动手势 其value值可以是开始与结束拖动的位置。

b.MagnificationGesture 放大捏合手势 其value值 返回 手指移动了多大、多远。

c.RotationGesture 旋转手势其value值 为角度。

关于手势的分类与介绍:http://www.neter8.com/ios/133.html

4、非离散手势的过程监听

非离散手势还有一个.updating可以监听手势的执行过程,我们需要定义一个@GestureState var配合使用:

@GestureState var myGestureState: MyGestureStateType = <你的类型值>

与@State非常相似,可以存放你想要的任何类型,但这是.updating手势更新中专用状态,随着手势不同,此值会实时更新,当完成了手势更新刚此值回到初始值状态。

var theGesture: some Gesture{
     DragGesture( ... )
         .updating($myGestureState) { value, myGestureState, transaction in
          myGestureState = value //myGestureState 传入的目的就是可以被修改的,实际是$myGestureState绑定的代理变量
         }
}

视频第39分左右,对上面3个参数进行了讲解,这是本课中需要理解的重点内容,$myGestureState 是告诉系统你使用的哪个@GestureState,闭包里有3个参数,随着手指的移动和手势的进展,闭包被重复的调用 。闭包里的value参数与onEnded里的参数相同(取决于手势),第二个参数 myGestureState 是一个输入输出的参数(我们通过修改此参数值以达到更新被@GestureState修饰的变量,所以这个命名可以随意的,这只是一个被绑定参数$myGestureState的别名而以),可以修改自己的@GestureState修饰的变量,否则@GestureState只是一个只读的参数。第三个transaction 是与动画有关的,这需要查阅其它资料,视频里没有讲到,并且在后续的学习中也未使用过。

除了上面的.onEnded与.updating,swiftUI还提供一个.onChanged的监听:

var theGesture: some Gesture{
     DragGesture( ... )
         .onChanged{ value in //这里只提供value
          /* 根据手势并通过value参数处理事务 */
         }
}

三、双击缩放手势演示(46:30)

1、定义适合屏幕大小的函数:

现在拖入到画布的图片有大有小,我们希望有一个双击手势,让背影自动调整到合适屏幕的尺寸。首先我们要定义一个可变的缩放比例值:

@State private var zoomScale: CGFloat = 1 //默认缩放比例(配合成为.scaleEffect的参数)
//通过计算返回图片被缩放的比例
private func zoomToFit(_ image: UIImage?, in size: CGSize) {
    //let image = image,后面的image是来算参数let image: UIImage?,判断图片的和容器值需要都大于0
    if let image = image, image.size.width > 0, image.size.height > 0, size.width > 0, size.height > 0  {
        let hZoom = size.width / image.size.width //返回宽的比例值
        let vZoom = size.height / image.size.height//返回高的比例值
        zoomScale = min(hZoom, vZoom)//修改zoomScale选择一个更小的值
    }
}

2、在背景和表情上应用缩放效果:

OptionalImage(uiImage: document.backgroundImage)
    .scaleEffect(zoomScale)//增加背影图片缩放

表情同步缩放:

Text(emoji.text)
     .scaleEffect(zoomScale)//表情缩放

3、处理被缩放后坐标偏移的问题:

当我们的图被缩放后,需要对其坐点更新以达到从中心点缩放的目的:

//反转坐标为Int元组值
private func convertToEmojiCoordinates(_ location: CGPoint, in geometry: GeometryProxy) -> (x: Int, y: Int) {
    let center = geometry.frame(in: .local).center
    let location = CGPoint(
        x: (location.x - center.x) * zoomScale,//将x结果 * 缩放比例
        y: (location.y - center.y) * zoomScale//将y结果 * 缩放比例
    )
    return (Int(location.x), Int(location.y))
}

//将表情坐标转换成CGPoint值
private func convertFromEmojiCoordinates(_ location: (x: Int, y: Int), in geometry: GeometryProxy) -> CGPoint {
    let center = geometry.frame(in: .local).center
    return CGPoint(
        x: center.x + CGFloat(location.x) * zoomScale, //坐标x以中心x * 缩放 出发
        y: center.y + CGFloat(location.y) * zoomScale //坐标y以中心y * 缩放 出发
    )
}

4、让拖入的表情也自动缩放

document.addEmoji(
    String(emoji),
    at: convertToEmojiCoordinates(location, in: geometry),
    size: defaultEmojiFontSize / zoomScale //向画布拖入后自动缩放
)

5、增加双击自动缩放的手势

我们先在Color.white的背影上增加一个应用自定义手势,传入当然可用空间的尺寸。

Color.white.overlay(...)
     .gesture(doubleTapToZoom(in: geometry.size))//应用自己定义的doubleTapToZoom手势

实现手势 doubleTapToZoom,当双击完成后我们配合动画并通过zoomToFit函数来修改zoomScale的值,然后通过scaleEffect实现缩放,因为zoomScale值在上面的算法处也有修改也会导致其它参数的联动修改:

//定义一个手势,传入geometry.size的值
private func doubleTapToZoom(in size: CGSize) -> some Gesture {
    TapGesture(count: 2)
        .onEnded {
            withAnimation {
                //应用动画通过zoomToFit修改zoomScale的值,然后通过scaleEffect实现缩放
                zoomToFit(document.backgroundImage, in: size)
            }
        }
}

在func zoomToFit里,我们限制了必须  if let image = image才可以进行一个缩放功能,所以当没有背影图片的时候双击是看不到任何的效果的。

6、修改图片越界问题:

当我们的背影图片过大的时候会越界,所以我们将画布可用范围进行限制。我们在docmentBody上增加一个:

.clipped() //将此视图剪辑到其边界矩形框架中。

超过本视图的内容不会越到下一个视图上去,自动的被隐藏掉了。

四、捏合手势(59:56)

在上面我们已完全了当双击背影图片将自动将图片缩放到画布大小,现在我们需要通过非离散手势的捏合手势完成缩放微调操作。

1、应用捏合手势:

我们需要在整个画板上使用捏合手势,放入下面的代码:

.gesture(zoomGesture())//应用zoomGesture()手势

2、实现zoomGesture()

//捏合手势修改zoomScale的值以达到缩放效果
private func zoomGesture() -> some Gesture {
    MagnificationGesture()//捏合手势(非离散手势)
        .onEnded { gestureScaleAtEnd in
            zoomScale *= gestureScaleAtEnd //修改zoomScale的值(*=)
        }
}

现在我们可以通过按住Opt键模拟捏合手势效果。结果发现能缩放但效果很僵硬,就算应用动画也只是手指松开后慢慢的播放动画。所以我们需要使用到上面学到的@GestureState 来解决这个问题。

3、让捏合实时更新

首先我们要定义一个可以监听手势更新状态的值,用于跟踪状态的实时情况:

@GestureState private var gestureZoomScale: CGFloat = 1

然后修改还需要将原来的zoomScale修改为steadyStateZoomScale而zoomScale则变成了一个计算属性:

@State private var steadyStateZoomScale: CGFloat = 1 //函数将要修改的值
@GestureState private var gestureZoomScale: CGFloat = 1 //通过手势实现更新的值
private var zoomScale: CGFloat {
    steadyStateZoomScale * gestureZoomScale //缩放比例根据2个值更新
}

记得将zoomToFit与zoomGesture里的值也更新为steadyStateZoomScale(视频1:05:30处有详细说明),重点理解zoomScale的计算属性算法

4、steadyStateZoomScale实时更新(1:06:29)

经过上面的修正参数后,我们将为zoomGesture增加一个.updating监听实时更新的值。

private func zoomGesture() -> some Gesture {
    MagnificationGesture()
        .updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in
            gestureZoomScale = latestGestureScale //修改实时更新的值为手势最新动态
        }
        .onEnded { gestureScaleAtEnd in
            steadyStateZoomScale *= gestureScaleAtEnd//接收变量重命名了(原名zoomScale)
        }
}

.updating的第3个接收参数是用于动画的,我们基本上用不到,课程里也没有讲这个怎么使用所以使用了忽略_代替。而第二个参数就像课程理论问题说到的,就只是一个代理,其指针指向了传入的绑定参数$gestureZoomScale,更新这个代理就是在修改$gestureZoomScale的值,所以通常情况下此变量的名字与绑定名字是一样的(1:08:14)。

五、平移手势(1:12:33)

上面的代码已实现了双击缩放,捏合缩放效果,现在我们再通过平移手势实现拖动画布的效果。

1、定义平衡要更新的变量:

和上面使用的变量一样,我们需要复制一份修改一下名字即可:

//平移手势参数(这里的+号功能是通过扩展CGSize实际的,详细请看上一课的扩展代码)
@State private var steadyStatePanOffset = CGSize.zero //平移函数将要修改的值
@GestureState private var gesturePanOffset = CGSize.zero //通过手势实现更新的平移值
private var panOffset: CGSize {
    (steadyStatePanOffset + gesturePanOffset) * zoomScale //平移位置根据2个值更新后还需要 * zoomScale
}

CGSize是不能相加的,之所以可以在上面代码里实现计算功能,因为上一节课的扩展里已为其实现了func +功能。理解panOffset计算属性为重点内容

2、修正数据转换问题(1:15:20)

在双击缩放的时间我们修正了convertFromEmojiCoordinates与convertToEmojiCoordinates这两个函数。由于我们是移动,所以坐标是肯定会改变的,所以我们需要convertToEmojiCoordinates减去 panOffset 的高、宽值。而convertFromEmojiCoordinates则需要加上 panOffset的宽、高值,即:

private func convertToEmojiCoordinates(_ location: CGPoint, in geometry: GeometryProxy) -> (x: Int, y: Int) {
    let center = geometry.frame(in: .local).center
    let location = CGPoint(
        x: (location.x - panOffset.width - center.x) * zoomScale,//x坐标减去 panOffset.width
        y: (location.y - panOffset.height -  center.y) * zoomScale//y坐标减去 panOffset.height
    )
    return (Int(location.x), Int(location.y))
}


private func convertFromEmojiCoordinates(_ location: (x: Int, y: Int), in geometry: GeometryProxy) -> CGPoint {
    let center = geometry.frame(in: .local).center //来源于geometry的中心点(能过扩展代码extension CGRect实现)
    return CGPoint(
        x: center.x + CGFloat(location.x) * zoomScale + panOffset.width, //坐标x 加上  + panOffset.width
        y: center.y + CGFloat(location.y) * zoomScale + panOffset.height //坐标y 加上  + panOffset.height
    )
}

3、双击后清除平移

我们需要双击后让我们手工平移的状态重置,我们需要在zoomToFit后面增加参数:

steadyStatePanOffset = .zero//自动缩放时让移动位置归零

4、创建平移手势(1:18:00)

经过上面的准备工作后,我们需要建立一个平移的手势。

//平移手势
private func panGesture() -> some Gesture {
    DragGesture()
        .updating($gesturePanOffset) { latestDragGestureValue, gesturePanOffset, _ in
            gesturePanOffset = latestDragGestureValue.translation / zoomScale//实时更新平移位置
        }
        .onEnded { finalDragGestureValue in
            //手势结束后,位置更新
            steadyStatePanOffset = steadyStatePanOffset + (finalDragGestureValue.translation / zoomScale)
        }
}

这里需要重点理解.updating实时更新时的算法。这里需要查看文档.translation 从拖动手势开始到拖动手势的当前事件的总转换。

5、将手势应用到视图上(1:22:22)

将原来的.gesture(zoomGesture())替换为下面的代码:

.gesture(panGesture().simultaneously(with: zoomGesture()))//可同时应用多个非离散手势

不建议在一个视图上同时应用超过一个的手势。所以我们需要使用.simultaneously使用。

六、课后总结

本课的内容重点是理解各种手势的应用及位置更新与缩放算法。

 

除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:http://www.neter8.com/ios/155.html
标签:多线程手势cs193pKwok最后编辑于:2021-08-29 21:29:48
0
感谢打赏!

《CS193p2021学习笔记第十课:Multithreading Demo Gestures 多线程演示与手势》的网友评论(0)

本站推荐阅读

热门点击文章