Swift:自定义视图(上)

2022-01-21  本文已影响0人  时光啊混蛋_97boy

原创:问题解决型文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录


一、滑动拼图验证弹窗

如上图所示,该弹窗最下层是一个阴影蒙层视图,点击该蒙层可以让该弹窗消失掉。弹窗位于APP中央展示,弹窗上部分的底层是一个背景视图,每次刷新弹窗该背景视图都会发生改变,同时其3个缺失的部位也会发生改变,最左边是我们的积木,验证的方式就是拖动左方的积木前进直到嵌入到凹槽之中。弹窗的下部分为提示文本,还有一个滑动条,滑动条中是一个滑块,我们可以拖拽该滑块左右滑动。分析完成该视图的构成元素之后,我们就可以来实现了。

1、滑动拼图在什么场景下使用

当用户处于APP登陆界面,因为登陆频繁导致请求验证码的时候得到了错误信息,此时我们判断错误码是否因为用户频繁登录才导致的,倘若是就弹出滑动拼图验证弹窗,用户验证成功后就重新调用请求验证码的方法并且这次在其中带入了拼图验证结果信息。

fileprivate func getVerifyCode(phone: String, type: String = "login", msgType: String = "text", captcha: String = "", capname: String? = "captcha", completion: @escaping (_ error: MLError?) -> Void) {
    vmodel.getVerifyCode(phone: phone, type: type, msgType: msgType, captcha: captcha, capname: capname) { [weak self] (error) in
        guard let weakSelf = self else { return }
        if error != nil {
            if error?.code == MLLoginErrorCode.VerifyCodeNumTooMuchError.rawValue || error?.code == MLLoginErrorCode.VerifyCodeNumHourTooMuchError.rawValue || error?.code == MLLoginErrorCode.VerifyCodeNumDayTooMuchError.rawValue {
                MLCaptchaView.show(kCaptchaServiceHost, type: .puzzle, phone: phone) { v in
                    if !v.isEmpty&&v.length>0 {
                        weakSelf.getVerifyCode(phone: phone, type: type, msgType: msgType, captcha: v, capname: "captchaVerification", completion: completion)
                    } else {
                        completion(error)
                    }
                }
            } else {
                completion(error)
            }
        } else {
            completion(error)
        }
    }
}

可以看到验证信息中的v是一个很长的经过处理加密处理后的字符串。

倘若未验证成功则让滑块和积木都回到原点,并重新请求图片数据。


2、滑动拼图由哪些视图构成

上面我们使用MLCaptchaView.show方法唤起了滑动拼图弹窗。该方法的实现比较简单,我们只是创建了一个滑动拼图弹窗,再将show方法中的入参传给了该弹窗,最后将该弹窗视图加入到了window窗口中。

let KTHost : String = "https://med-captcha-qa.medlinker.com"
private var cellPhone : String?
private var currentType = CaptchaType.puzzle
private var hostAddress : String = KTHost
private var completeBlock: ((String) -> Void)?
public class func show(_ host: String, type: CaptchaType, phone: String = "" , completeBlock block: @escaping ((String) -> Void)) {
    let view = MLCaptchaView(frame: UIScreen.main.bounds, type: type)
    view.cellPhone = phone
    view.completeBlock = block
    view.hostAddress = host
    UIApplication.shared.windows.first?.addSubview(view)
}

看来关键点在于这个创建弹窗的初始化方法了。在这个初始化方法中,我们将创建公用的视图元素和根据验证类型创建不同的验证元素进行了区分。

private init(frame: CGRect, type: CaptchaType) {
    super.init(frame: frame)
    currentType = type
    initBaseView()
    setCaptchaType(type)
}

在设置校验类型的时候我们进行了判断,倘若是滑动拼图我们就创建拼图视图,倘若是字符校验我们就创建字符视图。

enum CaptchaType: Int {
    case puzzle     = 0 //滑动拼图
    case clickword   = 1 //字符校验
}
private func setCaptchaType(_ type: CaptchaType) {
    switch type {
    case .puzzle:
        initPuzzleView()
    case .clickword:
        initClickWordView()
    }
    requestCaptchaData()
}
创建通用视图元素
蒙层背景视图

蒙层视图布满全部弹窗,为半透明的视图,其支持用户点击,点击的效果是关闭弹窗。

shadowView.frame = self.bounds
shadowView.isUserInteractionEnabled = true
let ges = UITapGestureRecognizer(target: self, action: #selector(close))
shadowView.addGestureRecognizer(ges)
shadowView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
addSubview(shadowView

在关闭弹窗的时候,倘若完成回调存在就调用其,只是传入空字符串。关闭弹窗的方式就是将弹窗视图从window中移除掉。

@objc private func close(successString: String = "") {
    if let block = completeBlock {
        block(successString)
    }
    removeFromSuperview()
}
弹窗容器视图

容器视图位于视图中央展示,其展示样式的底部左右为圆角。

contentView.frame = CGRect(x: 0, y: 0, width: 310, height: 285)
contentView.center  = center
contentView.backgroundColor = .white
addSubview(contentView)

let shapeLayer:CAShapeLayer = CAShapeLayer()
shapeLayer.path = UIBezierPath.init(roundedRect: CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height), byRoundingCorners: UIRectCorner(rawValue: UIRectCorner.bottomLeft.rawValue | UIRectCorner.bottomRight.rawValue), cornerRadii: CGSize(width: 10, height: 10)).cgPath
contentView.layer.mask = shapeLayer
弹窗图片视图

这里只是设置了该图片视图的布局样式,没有设置其展示的具体内容,因为图片内容来自于接口数据。

baseImageView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
baseImageView.clipsToBounds = true
baseImageView.frame = CGRect(x: 0 , y:  0, width: 310, height: 155)
contentView.addSubview(baseImageView)
创建拼图校验视图元素
提示文本

推动视图有一个提示滑动验证的文本,我们需要将其添加到视图上。

tipLabel.frame = CGRect(x: 0, y: baseImageView.frame.height + 40, width: contentView.frame.width, height: 22)
tipLabel.text = "拖动滑块,把图片拼合就验证成功啦!"
tipLabel.textColor = UIColor.ml_hex( 0x2A2A2A)
tipLabel.textAlignment = .center
tipLabel.font = UIFont(name: "PingFang-SC-Regular", size: 14)
contentView.addSubview(tipLabel)
滑块容器视图

这个就是上面带着点微红的圆角长条。

sliderView.frame = CGRect(x: 10, y: baseImageView.frame.maxY + 80, width: contentView.width-20, height: 38)
sliderView.backgroundColor = UIColor.ml_hex(0xF35656, alpha: 0.1)
sliderView.layer.cornerRadius = 19
contentView.addSubview(sliderView)
左边积木视图

积木视图来自于接口数据,这里我们需要从base64编码的字符串中获取到图片数据再将其转化为图。在设置积木视图的布局的时候,其宽高由于不确定,所以直接从请求到的图片中获取。

let puzzleImg = base64ConvertImage(reponseModel.jigsawImageBase64)
puzzleImageView.frame = CGRect(x: 0, y: 0, width: puzzleImg.size.width, height: puzzleImg.size.height )
baseImageView.addSubview(puzzleImageView)

base64ConvertImage方法可以让我们从base64编码的字符串中获取到图片数据再将其转化为图。

private func base64ConvertImage(_ imageStr: String ) -> UIImage {
    let data = Data.init(base64Encoded: imageStr, options: .ignoreUnknownCharacters)
    let image = UIImage(data: data!)
    return image ?? UIImage()
}

可以看到,无论是上面的带凹槽的基本图片还是现在我们添加的积木图片,都只是进行布局并没有放入内容,因为真正的图片来自于接口模型CaptchaResponseModel,倘若请求到了数据设置了该模型那么此时我们才真正设置图片内容。

class CaptchaResponseModel:NSObject, HandyJSON {
    var token: String = ""// 获取验证码得到的token
    var result: String = ""
    var originalImageBase64: String = ""// 凹槽图片
    var jigsawImageBase64: String = ""// 积木图片
    var secretKey: String = ""// 加密key
    var wordList: [Any] = []// 点选文字集合
    
    required override init() {}
}
private var reponseModel = CaptchaResponseModel() {// 响应模型
    didSet {
        baseImageView.image = base64ConvertImage(reponseModel.originalImageBase64)
        puzzleImageView.image = base64ConvertImage(reponseModel.jigsawImageBase64)
    }
}
推动视图

这个是红色的拖动滑块按钮,虽然是一张图片但是支持拖动手势,当拖动该按钮的时候,上图片视图左边的积木也会跟着发生移动。

thumbView.frame = CGRect(x: 4, y: 4, width: 98, height: 30)
thumbView.isUserInteractionEnabled = true
sliderView.addSubview(thumbView)
let pan = UIPanGestureRecognizer(target: self, action: #selector(slidThumbView(sender:)))
thumbView.addGestureRecognizer(pan)

当我们拖动滑条的时候会调用slidThumbView方法,在该方法中首先我们可以获取到当前滑动的位置point

let point = sender.translation(in: sliderView)

由于我们就是要通过滑动积木来验证登录频繁的问题是否是由于机器人导致的,所以当滑块匀速滑动的时候我们也判断其为机器人在操作,这种情况下是失败的。我们创建了容纳每次滑动差值的数组offsetXList,将本次滑动的位置和上次所在的位置做差值,再将该差值放入到数组之中。

private var lastPointX: CGFloat = .zero// 最后拖动点
private var offsetXList: Set<CGFloat> = []// 匀速滑动时判定为机器操作则失败

if lastPointX != .zero {
    let offetX  = point.x - lastPointX
    offsetXList.insert(offetX)
}
lastPointX = point.x

倘若滑动进度条没有达到边界,即maxX没有到达进度条的右边边界偏左4点的位置,并且该滑条又确实移动了那么我们就来移动该滑条和积木。为了保证滑条和积木移动进度的一致,我们使用x_ratio来计算了滑条和积木移动进度的比例关系,x_ratio的值最好在initPuzzleView方法中进行计算而不是放到slidThumbView方法中计算,因为这个值只需要计算一次就好了,而slidThumbView方法却会调用很多次。

if (thumbView.frame.maxX + 4 < sliderView.bounds.width && thumbView.frame.minX > .zero) {
    thumbView.transform = CGAffineTransform(translationX: point.x, y: 0)
    puzzleImageView.transform = CGAffineTransform(translationX: point.x * x_ratio, y: 0)
}

当用户完成滑动之后我们就可以调用接口来检查用户是否验证成功了。在checkResult这个方法中我们传入了调整比例后积木的x坐标。这里还有个需要注意的地方,刚才我们提到offsetXList用来判断是否是匀速滑动,匀速滑动时判定为机器操作则失败,所以当我们滑动完成之后需要在这里进行判断,因为offsetXList是一个Set集合,倘若是匀速滑动,那么就只会有一个值存在,否则会有多个值存在。

if (sender.state == .ended) && (offsetXList.count > 1) {
    checkResult(point.x * x_ratio)
}

3、滑动拼图该如何进行校验

在这个校验结果的方法之中,我们根据当前校验类型来分别做不同的处理。

private func checkResult(_ puzzleImageX: CGFloat?) {
    switch currentType {
    case .puzzle:
        guard let puzzleImageX = puzzleImageX else { return }
    case .clickword:
    }
}

我们目前进行的是拼图,拼图校验最重要的就是看可以左右移动的积木是否可以放到凹槽中去,由于积木只能左右移动暂不支持上下,所以我们可以不管y坐标将其写死为定植这里为5。将坐标转为CaptchaRequestPointModel点模型,由于后端要求传入的是JSON字符串,所以我们对此captcha请求点模型进行了序列化处理,将其转化为JSON字符串。

let captchaEncodeString = MLAESConfig.captchaPointjsonEncode(CaptchaRequestPointModel(x: puzzleImageX, y: 5))
class func captchaPointjsonEncode(_ pointModel: CaptchaRequestPointModel) -> String {
    var paramsString = ""
    do {
        let params = pointModel
        let data = try JSONEncoder().encode(params)
        paramsString = String(data: data, encoding: .utf8)!
    } catch{}
    return paramsString
}

倘若出于安全考虑需要进行加密处理,那么判断请求到的数据是否有secretKey,有则走加密 处理否则不走加密。我们这里引入了CryptoSwift库,使用使用AES-128-ECB加密模式,加密完成之后再将加密结果转成base64形式。

class func aesEncrypt(_ requestJson: String, _ secretKey: String = "XwKsGlMcdPMEhR1B") -> String {
    var encryptedBase64 = ""
    do {
        // 使用AES-128-ECB加密模式
        let aes = try AES(key: secretKey.bytes, blockMode: ECB(), padding: .pkcs7)
        // 开始加密
        let encrypted = try aes.encrypt(requestJson.bytes)
        // 将加密结果转成base64形式
        encryptedBase64 = encrypted.toBase64() ?? ""
    } catch { }
    return encryptedBase64
}

对编码后的JOSN字符串进行加密。

aesEncryptResult(captchaEncodeString)
private func aesEncryptResult(_ pointEncodeString: String) {
    var pointAesEncryptString = "";
    if self.needEncryption == true {
        pointAesEncryptString = MLAESConfig.aesEncrypt(pointEncodeString, reponseModel.secretKey)
    } else {
        pointAesEncryptString = pointEncodeString;
    }
    ...
}

通过上面的步骤获取到请求校验接口需要的数据后就可以调用requestCheckResult方法来进行请求了。

requestCheckResult(pointEncode: pointEncodeString, pointAesEncrypt: pointAesEncryptString)

这里又去调用了网络请求的类MLCaptchaViewModel中校验身份的方法。

func requestCheckResult(pointEncode: String = "", pointAesEncrypt: String) {
    MLCaptchaViewModel.captchaCheck(hostAddress, type:currentType, phone: cellPhone ?? "", pointAesEncrypt: pointAesEncrypt, token: reponseModel.token, success: { (model) in

    }) { (error) in

    }
}

captchaCheck方法中我们需要将加密后的坐标、验证码类型、获取验证码得到的token、用户手机号等数据作为请求参数封装起来。

class func captchaCheck(_ host: String, type: CaptchaType, phone : String = "",  pointAesEncrypt: String = "", token: String = "", success:@escaping (CaptchaResponseModel) ->(), failure:@escaping (Error) ->()) {
    let url = "\(host)/captcha/check"
    var typeString = "";
    switch type {
    case CaptchaType.puzzle:
        typeString = "blockPuzzle";
    case CaptchaType.clickword:
        typeString = "clickWord"
    }
    
    let params = [
        "pointJson": pointAesEncrypt,// 加密后的坐标
        "captchaType": typeString,// 验证码类型
        "token": token,// 获取验证码得到的token
        "data": phone// 用户手机号
    ]
    ...
}

再用地址和参数去请求网络,请求成功后再将获取到的校验相关数据传递出去给外界使用。

MLBaseRequest.sharedInstance.baseRequest(url: url, params: params, success: { (response) in
    if let jsonDictionary = response as? [String: Any], let model = CaptchaResponseModel.deserialize(from: jsonDictionary) {
        success(model)
    }
}) { (error) in
    failure(error)
}

请求网络使用的是Alamofire。对于 SetArray ,你可以使用 compactMap 来获得非空的集合,但是对于 Dictionary 来说,这个函数是不起作用的,这时候我们需要使用 compactMapValues函数来获得非空的字典。

static let session: Session = {
    let configuration = URLSessionConfiguration.default
    configuration.timeoutIntervalForRequest = 20
    return Session(configuration: configuration)
}()
let requestParams: [String : Any] = params
let requestMethod = method == MethodType.post ? HTTPMethod.post : HTTPMethod.get
let headers = ["Content-Type":"application/json"]
let header = HTTPHeaders(headers)

MLBaseRequest.session.request(url, method: requestMethod, parameters: requestParams.compactMapValues({ $0 }), encoding: JSONEncoding.default, headers: header).responseJSON { (response) in

}

倘若验证成功,就将成功的信息回调出去,否则回调错误信息。

switch response.result {
case .success:
    if let value = response.value {
        guard let json = value as? [String:Any] else {return}
        let repCode = json["errcode"] as? Int64
        let repMsg = json["errmsg"] as? String
        guard repCode == 0 else {
            let err = NSError(domain: "\(repMsg ?? "未知错误")", code: 0, userInfo: nil)
            failure(err)
            return
        }
        success(json["data"])
    }
case .failure(let error):
    failure(error)
}

校验身份的captchaCheck方法获取到请求结果数据response之后,将其解析为CaptchaResponseModel模型传递出去使用。

if let jsonDictionary = response as? [String: Any], let model = CaptchaResponseModel.deserialize(from: jsonDictionary) {
    success(model)
}

不过这里解析出来的model好像都没有使用上,在requestCheckResult方法中并没有使用到该mode,我们只是将tokenpointEncode拼接了一下,倘若需要加密的话再对拼接后的字符串进行了AES加密处理,最后将验证状态和拼接后的字符串回传了出去。

var successString = "\(self.reponseModel.token)---\(pointEncode)";
if (self.reponseModel.secretKey.count > 0) {
    successString = MLAESConfig.aesEncrypt(successString, self.reponseModel.secretKey)
}
self.showResult(true, successString: successString)

倘若我们在拖动的时候并没有将积木拖动到凹槽之中,那我们就会进入到失败回调,在requestCheckData方法中我们在展示结果的showResult方法中传入了验证失败和空的拼接字符串。

self.showResult(false, successString: "")

接下来我们看看showResult对于验证结果是如何进行处理的。倘若验证成功那么就直接关闭当前页面并且传入了成功的字符串。

func showResult(_ isSuccess: Bool, successString: String) {
    switch currentType {
    case .puzzle:
        if isSuccess {
            delayPerform {
                self.close(successString: successString)
            }
        } else {// 只有错误时才显示弹窗
            
        }
    case .clickword:
    }
}

只有当失败的时候我们才展示提示视图,所以可以通过懒加载的方式来创建该视图。该视图创建在了图片视图下方位置高度为20。

private lazy var failTipView: UIView = {
    let view = UIView(frame: CGRect(x: 0, y: baseImageView.height, width: baseImageView.width, height: 20))
    baseImageView.insertSubview(view, at: 0)
    
    let tipLabel = UILabel(frame: CGRect(x: 5, y: 0, width: baseImageView.width - 20, height: view.bounds.height))
    view.backgroundColor = UIColor("d9534f").withAlphaComponent(0.4)
    let attrString = NSMutableAttributedString(string: "验证失败: 再试一下吧~", attributes: [NSAttributedString.Key.foregroundColor: UIColor.black])
    attrString.addAttributes([NSAttributedString.Key.foregroundColor: UIColor.red], range: NSRange(location: 0, length: 5))
    tipLabel.attributedText = attrString
    tipLabel.font = UIFont.systemFont(ofSize: 12)
    view.addSubview(tipLabel)
    
    return view
}()

通过动画我们将视图上移了20点。使用identity属性可以还原由于CGAffineTransform而发生的改变,换句话说所有CGAffineTransform发生的改变都会被还原,所以当失败后我们将滑块、积木还有失败提示文本都复原了,完成之后再重新请求接口刷新视图。

@objc func refresh() {
    requestCaptchaData()
}
UIView.animate(withDuration: 0.25, animations: {
    self.failTipView.transform = CGAffineTransform(translationX: 0, y: -20)
}) { (finish) in
    UIView.animate(withDuration: 0.5, delay: 0.75, options: .allowUserInteraction, animations: {
        self.failTipView.transform = .identity
    }, completion: nil)
}

UIView.animate(withDuration: 0.75, animations: {
    self.thumbView.transform = .identity
    self.puzzleImageView.transform = .identity
}) { (finish) in
    self.refresh()
}

对于获取验证弹窗数据的接口,其底层的请求方式和之前校验身份的请求基本类似。

private func requestCaptchaData() {
    MLCaptchaViewModel.getCaptchaData(hostAddress, type: currentType) { (model) in

    } failure: { (error) in

    }
}
class func getCaptchaData(_ host: String, type: CaptchaType ,success:@escaping (CaptchaResponseModel) -> (), failure:@escaping (Error) ->()) {
    
    let url = "\(host)/captcha/get"
    var typeString = "blockPuzzle";
    switch type {
    case CaptchaType.puzzle:
        typeString = "blockPuzzle";
    case CaptchaType.clickword:
        typeString = "clickWord"
    }
    let params = [
        "captchaType": typeString,// 验证码类型
        "clientUid": MLAppInfo.shared.sysd// 唯一标识:客户端UI组件id,组件初始化时设置一次,UUID(非必传参数)
    ]
    
    MLBaseRequest.sharedInstance.baseRequest(url: url, params: params, success: { (response) in
        if let jsonDictionary = response as? [String: Any], let model = CaptchaResponseModel.deserialize(from: jsonDictionary) {
            success(model)
        }
    }) { (error) in
        failure(error)
    }
}

真正产生区别的是对其model的处理方式。倘若请求成功我们就对响应模型进行了赋值,并且判断了是否需要做加密处理。最后再拿获取到的响应数据去刷新视图。

self.reponseModel = model
if (self.reponseModel.secretKey.count > 0) {
    self.needEncryption = true
} else {
    self.needEncryption = false
}
self.refreshCaptchaView(self.currentType)

在刷新视图之后我们就将判断匀速的集合给重置了,防止下次进来的时候集合中还存在上次的移动位置。刷新视图之前需要将旧的视图移除掉,这就解释了为什么我们的失败提示文本框在刷新后就不见了并不会呈现在图片下面,因为其懒加载的时候创建好立刻就进行动画了,动画完成后马上就移除掉了。

func refreshCaptchaView(_ type: CaptchaType) {
    switch type {
    case .puzzle:
        offsetXList.removeAll()
        sliderView.subviews.forEach {$0.removeFromSuperview()}
        initPuzzleView()
    case .clickword:
        break
    }
}

倘若连验证数据都没有请求到,那么我们就直接弹窗提示用户数据请求失败。

self.needEncryption = false
self.close()
let nsError = error as NSError
let alert = UIAlertController(title: "提示", message: nsError.domain, preferredStyle: .alert)
let buttonOK = UIAlertAction(title: "好的", style: .default, handler: nil)
alert.addAction(buttonOK)
self.currentVC().present(alert, animated: true, completion: nil

这里需要获取到当前控制器来展示弹窗。

func currentVC() ->UIViewController {
    var vc = UIApplication.shared.keyWindow?.rootViewController
    if (vc?.isKind(of: UITabBarController.self))! {
        vc = (vc as! UITabBarController).selectedViewController
    } else if (vc?.isKind(of: UINavigationController.self))! {
        vc = (vc as! UINavigationController).visibleViewController
    } else if (vc?.presentedViewController != nil) {
        vc =  vc?.presentedViewController
    }
    return vc!
}

4、点击文字校验是如何实现的

上面是滑动拼图校验实现的全过程,接下来我们来分析一下如何实现拼图校验,实现方式基本同上类似。依然来到最关键的show方法,这次的入参拼图类型改为clickword字符校验,然后来到设置校验类型的setCaptchaType方法中,我们在case .clickword:下调用了创建文字校验的initClickWordView方法。

在这个方法中我们首先创建红色方框中提示点击文本。接着为上方的图片添加了点击事件。

operationTipsLabel.frame = CGRect(x: 0, y: 0, width: baseImageView.frame.size.width, height: 38)
operationTipsLabel.textAlignment = .center
operationTipsLabel.font = UIFont.systemFont(ofSize: 18)
operationTipsLabel.setNeedsLayout();
sliderView.addSubview(operationTipsLabel)

baseImageView.isUserInteractionEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(tapBaseImageView(sender:)))
baseImageView.addGestureRecognizer(tap)

针对于文本的点击事件,我们通过tap手势获取到点击的坐标位置,然后判断倘若并没有达到接口要求的点击文案个数的话就创建一个点击数字标签,显示当前已经点击的文案个数。倘若已经达到了规定个数那么就更改提示文案为校验进行中。

@objc func tapBaseImageView(sender: UITapGestureRecognizer) {
    let point = sender.location(in: sender.view)
    
    if reponseModel.wordList.count > 0 && tapLabelPointList.count < reponseModel.wordList.count {
        tapLabelPointList.append(point)
        
        let tapCountLabel = UILabel()
        let tapSize:CGFloat = 20.0
        tapCountLabel.frame = CGRect(x: point.x - tapSize * 0.5, y: point.y - tapSize * 0.5, width: tapSize, height: tapSize)
        tapCountLabel.backgroundColor = UIColor.ml_hex(0x4cae4c)
        tapCountLabel.textColor = .white
        tapCountLabel.text = "\(tapLabelPointList.count)"
        tapCountLabel.textAlignment = .center
        tapCountLabel.layer.cornerRadius = tapSize * 0.5
        tapCountLabel.layer.masksToBounds = true
        baseImageView.addSubview(tapCountLabel)
    }
    
    if reponseModel.wordList.count > 0 && tapLabelPointList.count == reponseModel.wordList.count {
        checkResult(nil)
    }
}

在校验的时候需要将点击的每个点进行JOSN编码再将其进行加密处理。最后将加密和编码后的字符串调用requestCheckResult进行请求接口校验。

class func clickWordJsonEncode(_ pointList: Any) -> String {
    var paramsString = ""
    do {
        let newData = try JSONSerialization.data(withJSONObject: pointList, options: .fragmentsAllowed)
        paramsString = String(data: newData as Data, encoding: .utf8)!
    } catch {}
    return paramsString
}
var pointsList: [Any] = []
for point in tapLabelPointList {
    pointsList.append(["x": point.x, "y": point.y])
}
let clickWordEncodeString = MLAESConfig.clickWordJsonEncode(pointsList)
aesEncryptResult(clickWordEncodeString)

文字校验有好几种状态,包括点击个数未达标时候的请依次点击、个数达标时候的进行中、校验失败和校验成功的结果。针对于这几张不同的状态,分别设置了相应的UI展示效果。

func setCurrentClickWordResultType(_ type: CaptchaResult) {
    switch type {
    case .normal:
        if (reponseModel.wordList.count > 0) {
            var wordList: [String] = []
            for word in reponseModel.wordList {
                wordList.append("\(word)")
            }
            let wordString = wordList.joined(separator: ",")
            operationTipsLabel.text = "请依次点击【\(wordString)】"
            operationTipsLabel.textColor = .black
        }
        sliderView.layer.borderColor = UIColor.ml_hex(0xF35656, alpha: 0.1).cgColor
    case .progress:
        tapLabelPointList.removeAll()
        operationTipsLabel.text = "加载中…"
        operationTipsLabel.textColor = UIColor.black
        sliderView.layer.borderColor = UIColor.ml_hex(0xF35656, alpha: 0.1).cgColor
    case .success:
        operationTipsLabel.text = "验证成功"
        operationTipsLabel.textColor = UIColor.ml_hex(0x4cae4c)
        sliderView.layer.borderColor = UIColor.ml_hex(0x4cae4c).cgColor
    case .failure:
        operationTipsLabel.text = "验证失败"
        operationTipsLabel.textColor = UIColor.ml_hex(0xd9534f)
        sliderView.layer.borderColor = UIColor.ml_hex(0x4cae4c).cgColor
    }
}

在请求网络接口的时候我们将提示文本设置为加载中。

func requestCaptchaData() {
    if currentType == .clickword {
        setCurrentClickWordResultType(.progress)
    }
    ...
}

针对显示结果的showResult方法,倘若请求成功则设置提示状态为成功关闭窗口,倘若请求失败则提示失败,1秒之后再刷新窗口。

if isSuccess {
    setCurrentClickWordResultType(.success)
    delayPerform {
        self.close()
    }
} else {
    setCurrentClickWordResultType(.failure)
    delayPerform {
        self.refresh()
    }
}

在验证失败刷新视图的时候需要将图片视图的所有子视图全部移除掉,再设置提示文本为点击个数未达标时候的请依次点击。

baseImageView.removeAllSubviews()
setCurrentClickWordResultType(.normal)

5、还有什么值得注意的

反序列化

之前我们看到了如何进行序列化,这里作为扩展知识,我们来看看如何将点击文案进行反序列化。

class func clickWordJsonDecode(_ jsonString: String) -> Any{
    do {
        guard let jsonObject = jsonString.data(using: .utf8) else { return "" }
        let dictionary = try JSONSerialization.jsonObject(with: jsonObject, options: .mutableContainers)
        return dictionary
    } catch {}
    return ""
}
解密

之前我们使用AES-128-ECB加密模式对JSON字符串进行了加密,现在我们尝试对其进行解密。

class func aesDncrypt(_ encodeString: String, _ secretKey: String = "XwKsGlMcdPMEhR1B") -> String{
    var decryptString = ""
    do {
        // 使用AES-128-ECB加密模式
        let aes = try AES(key: secretKey.bytes, blockMode: ECB(), padding: .pkcs7)
        // 从加密后的base64字符串解密
        decryptString = try encodeString.decryptBase64ToString(cipher: aes)
    } catch {}
    return decryptString
}

二、提示弹窗

1、提示弹窗由哪些视图构成

我们可以通过如下的方式来创建提示弹窗并展示出来。

MLAlertView(title: "提示", message: "刚刚您退出App,是因为训练引起了不舒服", destructButtonIndex : 1 ,buttonTitles: ["不是", "是"]) {(index) in
    if index == 1 {
        ...
    }
}.show()

在这个初始化方法之中我们传入了标题、提示信息、弹窗类型、破坏性按钮下标、是否展示取消按钮、点击按钮回调,虽然入参挺多,但是我们都提供了默认值。

public init(title: String?, message: String?, type: AlertViewType = .defaultAlert, destructButtonIndex: Int = -1, showCancelButton: Bool = false, buttonTitles: [String], clickBlock: ((_ buttonIndex: Int) -> Void)?) {
    super.init(nibName: nil, bundle: nil)

    self.title = title
    self.message = message
    self.showCancelClose = showCancelButton
    self.buttonTitles = buttonTitles
    self.type = type
    self.destructButtonIndex = destructButtonIndex
    self.clickBlock = clickBlock
    
    createSubviews()
}

在创建视图元素的createSubviews方法中,第一步我们首先创建了标题文本。创建标题文本的时候为其设置了属性文本,也为其计算了文本的高度。

private func createTitleLabel() {
    let attributeTitle = generateTitleAttributeString()
    guard let titleText = attributeTitle else { return }
    
    let titleHeight = caculateAttributeStringHeight(text: titleText)
    guard titleHeight > 0 else  { return }

    titleLabel = UILabel()
    titleLabel.numberOfLines = 0
    titleLabel.attributedText = titleText
    titleLabel.textAlignment = NSTextAlignment.center
    titleLabel.font = MLAlertView.kBoldTextFont
    titleLabel.textColor = MLTheme.color.black
    titleLabel.frame = CGRect(x: MLAlertView.kSplitLineDefaultHorizeSpace, y: MLAlertView.kTitleTopSpace, width: MLAlertView.kConstraintContainerWidth - MLAlertView.kSplitLineDefaultHorizeSpace * 2, height: titleHeight)
    containerView.addSubview(titleLabel)
}

倘若弹窗是支持文本输入的,那么我们就需要创建输入框。在创建文本框的时候将其放置在了一个容器视图之中,并且考虑到了没有提示标题时候的布局情况。

private func createInputLabel() {
    let inputBackgroundView = UIView()
    inputBackgroundView.backgroundColor = .ml_hex(0xf4f4f4)
    inputBackgroundView.layer.cornerRadius = 6
    inputBackgroundView.layer.masksToBounds = true
    containerView.addSubview(inputBackgroundView)

    inputBackgroundView.frame = CGRect(x: MLAlertView.kHorizeSpace, y: titleLabel.frame.maxY + 15, width: MLAlertView.kConstraintContainerWidth - MLAlertView.kHorizeSpace * 2, height: 40)
    
    textInputField = UITextField()
    textInputField.backgroundColor = .clear
    textInputField.placeholder = message
    textInputField.font = .systemFont(ofSize: 14)
    textInputField.becomeFirstResponder()
    textInputField.frame = CGRect(x: MLAlertView.kHorizeSpace, y: titleLabel.frame.maxY, width: MLAlertView.kConstraintContainerWidth - MLAlertView.kHorizeSpace * 2, height: 40)
    containerView.addSubview(textInputField!)
}

倘若我们的输入框支持多行输入,那么这时候我们就需要创建UITextView视图来支持该功能了。这里使用了自定义的UITextView视图,其支持占位文字的自定义。

private func createInputTextView(){
    textInputTextView = MLTextView()
    textInputTextView.placeholder = "请输入提示文字"
    textInputTextView.placeholderFont = MLTheme.font.pingFangSCRegular(size: 15)
    textInputTextView.placeholderX = 19
    textInputTextView.placeholderY = 12
    textInputTextView.placeholderColor =  MLTheme.color.titleColor_9A9A9A
    
    textInputTextView.backgroundColor = .clear
    textInputTextView.font = MLTheme.font.pingFangSCRegular(size: 15)
    textInputTextView.textColor = MLTheme.color.black
    textInputTextView.becomeFirstResponder()
    textInputTextView.layer.cornerRadius = 6
    textInputTextView.layer.masksToBounds = true
    textInputTextView.layer.borderWidth = 0.5
    textInputTextView.layer.borderColor = MLTheme.color.titleColor_DDDDDD.cgColor
    textInputTextView.textContainerInset = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
    textInputTextView.frame = CGRect(x: MLAlertView.kSplitLineDefaultHorizeSpace, y: 64, width: MLAlertView.kConstraintContainerWidth - MLAlertView.kSplitLineDefaultHorizeSpace * 2, height:
        120)
    containerView.addSubview(textInputTextView)
}

如果不是输入文本,只是作单纯地展示提示信息,那么就需要按照标题的样式再创建一份就好了。

private func createMessage() {
    let attributeMessage = generateMessageAttributeString()
    guard let messageText = attributeMessage else { return }
    
    let messageHeight = caculateAttributeStringHeight(text: messageText)
    guard messageHeight > 0 else  { return }
    
    messageLabel = UILabel()
    messageLabel.numberOfLines = 0
    messageLabel.attributedText = messageText
    messageLabel.textAlignment = NSTextAlignment.center
    messageLabel.font = MLAlertView.kNomalTextFont
    messageLabel.textColor = MLTheme.color.grayColor6A6A6A
    
    messageLabel.frame = CGRect(x: MLAlertView.kHorizeSpace, y: titleLabel.frame.maxY + 12, width: MLAlertView.kConstraintContainerWidth - MLAlertView.kHorizeSpace * 2, height: messageHeight)
    containerView.addSubview(messageLabel)
}

在创建按钮数组的时候,我们需要先获取到按钮的y坐标,然后设置按钮的相关属性,这里根据index判断哪一个按钮是特殊的,对其设置背景图片为紫色,其余为灰色,最后再依次设置每个按钮的布局。

private func getButtonYPostion() -> CGFloat {
    let space = (type == .inputAlert) ? 10 : 20
    var y = CGFloat(space)
    
    if type == .inputAlert {
        y += textInputField.frame.maxY
    } else if type == .textViewInputAlert {
        y += textInputTextView.frame.maxY
    } else {
        y += messageLabel.frame.maxY
    }
    
    return y
}
private func constructActionButtons () {
    var index = 0
    var x: CGFloat = 20
    let y = getButtonYPostion()
    for buttonTitle in buttonTitles {
        let button = UIButton()
        button.setTitle(buttonTitle, for: .normal)
        button.titleLabel?.font = MLAlertView.kNomalTextFont
        button.titleLabel?.textAlignment = NSTextAlignment.center
        button.addTarget(self, action: #selector(clickItem(button:)), for: .touchUpInside)
        
        let totalWidth = MLAlertView.kConstraintContainerWidth - 40 - CGFloat((buttonTitles.count - 1) * 12)
        let buttonWidth = totalWidth / CGFloat(buttonTitles.count)
        let itemSize = CGSize(width: buttonWidth, height: MLAlertView.kButtonHeight)
        if index == destructButtonIndex {
            button.setBackgroundImage(UIImage(color: MLTheme.color.appThemeColor, size: itemSize), for: .normal)
            button.setBackgroundImage(UIImage(color: MLTheme.color.appThemeColor, size: itemSize), for: .highlighted)
            button.setTitleColor(UIColor.white, for: .normal)
        } else {
            button.setBackgroundImage(UIImage(color:  MLTheme.color.lineColor_F1F1F1, size: itemSize), for: .normal)
            button.setTitleColor(MLTheme.color.titleColor_6A6A6A, for: .normal)
        }

        button.frame = CGRect(x: x, y: y, width: buttonWidth, height: MLAlertView.kButtonHeight)
        button.layer.cornerRadius = 20
        button.layer.masksToBounds = true
        x += (buttonWidth + 12)
        button.tag = index
        index += 1
        containerView.addSubview(button)
        buttonList.append(button)
    }
}

在创建完成上述的元素之后就可以去计算容器的高度,然后用这个高度去对容器进行布局。倘若是第一次进行布局,那么这时候键盘还没有展示出来,就将其置于中心展示。倘若是弹出了键盘之后的布局,那么就将其放置在键盘上方。

private func updateContainerFrame(isFirstUpdate: Bool) {
    let buttonY = getButtonYPostion()
    let width = MLAlertView.kConstraintContainerWidth
    let height = buttonY + MLAlertView.kButtonHeight + 20
    
    if isFirstUpdate {
        containerView.frame = CGRect(x: 0, y: 0, width: width, height: height)
        containerView.center = CGPoint(x: MLAlertView.kScreenWidth / 2, y: MLAlertView.kScreenHeight / 2)
    } else {
        let x = (MLAlertView.kScreenWidth - containerView.frame.width) / 2
        let y = MLAlertView.kScreenHeight - keyboardHeight - height - 10
        containerView.frame = CGRect(x: x, y: y, width: width, height: height)
    }
}

当观察到弹出了键盘,即键盘的高度存在之后那么我们就需要更新容器的布局。

private var keyboardHeight: CGFloat = 0 {/// 键盘高度
    didSet {
        if keyboardHeight > 0 {
            updateContainerFrame(isFirstUpdate: false)
        }
    }
}

综上,我们已经创建完成了所有的视图元素,整体来说如下:

private func createSubviews() {    
    containerView.backgroundColor = UIColor.white
    containerView.layer.cornerRadius = 10
    containerView.layer.masksToBounds = true
    view.addSubview(containerView)

    createTitleLabel()
    if type == .inputAlert {
        createInputLabel()
    } else if type == .textViewInputAlert {
        createInputTextView()
    } else {
        createMessage()
    }
    constructActionButtons()
    
    updateContainerFrame(isFirstUpdate: true)
}

2、提示弹窗支持哪些点击事件

显示隐藏弹窗

当我们点击灰色的遮罩背景的时候需要让弹窗消失掉,所以我们给背景加了一个点击手势触发消失事件。

view.backgroundColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.5)
view.isUserInteractionEnabled = true
view.addGestureRecognizer(UITapGestureRecognizer.init(target: self, action: #selector(hide)))

消失事件只是将弹窗窗口从视图中移除出去。

@objc private func hide() {
    UIView.animate(withDuration: 0.3, animations: {
        MLAlertView.window.alpha = 0
    }) { (success) in
        MLAlertView.window.removeFromSuperview()
        MLAlertView.window.removeAllSubviews()
        MLAlertView.window.isHidden = true
    }
}

至于弹窗窗口我们通过懒加载的方式创建了该窗口,并且将其放在了整个视图之中。

private static let window: UIWindow = {// 弹窗
    let window = UIWindow()
    window.windowLevel = .alert
    window.backgroundColor = .clear
    window.frame = CGRect(x: 0, y: 0, width: MLAlertView.kScreenWidth, height: MLAlertView.kScreenHeight)
    return window
}()

创建好弹窗窗口之后,我们就可以将其展示出来,这是和之前的隐藏相反的操作。

public func show() {
    let window = MLAlertView.window
    window.rootViewController = self
    window.alpha = 0.01
    window.isHidden = false
    UIResponder.ml_currentFirstResponder()?.resignFirstResponder()
    UIView.animate(withDuration: 0.3, animations: { () -> Void in
        window.alpha = 1
    })
}
观察键盘高度的改变

由于倘若是需要输入文字的弹窗,弹出键盘后我们需要将弹窗整体上移,也就是需要改变其布局,所以这里我们观察了键盘的尺寸改变。

NotificationCenter.default.addObserver(self, selector: #selector(MLAlertView.handleKeyboardFrameChange(keyboardNotification:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
@objc private func handleKeyboardFrameChange(keyboardNotification: NSNotification) {
    if let info = keyboardNotification.userInfo as? [String : AnyObject] {
        if let keyboardValue = info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
            keyboardHeight = keyboardValue.cgRectValue.size.height
        }
    }
}
点击按钮

在点击按钮的时候我们进行了判断,到底是想通过委托的方式进行处理还是想通过闭包的方式来进行处理。倘若是委托的方式那么还存在键盘输入的情况需要额外处理,这种情况需要传入文本框中的字符串给外界使用,倘若不是这两种情况,那么就只需要拿到点击的按钮即可。处理完成之后再将键盘隐藏掉即可。

@objc private func clickItem(button: UIButton) {
    if type == .inputAlert {
        textInputField.resignFirstResponder()
    } else if type == .textViewInputAlert {
        textInputTextView.resignFirstResponder()
    }
    
    if let alertViewDelegate = delegate {
        if type == .inputAlert {
            alertViewDelegate.alertView?(alertView: self, buttonIndex: button.tag, inputText: textInputField?.text ?? "")
        } else if type == .textViewInputAlert {
            alertViewDelegate.alertView?(alertView: self, buttonIndex: button.tag, inputText: textInputTextView?.text ?? "")
        } else {
            alertViewDelegate.alertView?(alertView: self, clickedButtonAtIndex: button.tag)
        }
    }
    
    if let actionBlock = self.clickBlock {
        actionBlock(button.tag)
    }
    
    hide()
}

倘若是委托的方式,我们在委托协议中定义了如下两个方法。其是可选实现的,一个用于输入弹窗,一个用于提示弹窗。

public weak var delegate: MLAlertViewDelegate?

@objc public protocol MLAlertViewDelegate: NSObjectProtocol {
    @objc optional func alertView(alertView: MLAlertView, clickedButtonAtIndex buttonIndex: Int)
    @objc optional func alertView(alertView: MLAlertView, buttonIndex: Int, inputText: String)
}

最后,我们又创建了一个初始化方法,在该方法中可以传入delegate来创建弹窗。

init(title: String?, message: String?, type: AlertViewType = .defaultAlert, delegate: MLAlertViewDelegate?, destructButtonIndex: Int = -1, buttonTitles: [String]) {
    super.init(nibName: nil, bundle: nil)
    
    self.title = title
    self.message = message
    self.delegate = delegate
    self.buttonTitles = buttonTitles
    self.type = type
    self.destructButtonIndex = destructButtonIndex
    
    createSubviews()
    
    NotificationCenter.default.addObserver(self, selector: #selector(MLAlertView.handleKeyboardFrameChange(keyboardNotification:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}

4、用到了哪些辅助方法

判断字符串是否为空
private func isEmptyString(text: String) -> Bool {
    let remainText = text.trimmingCharacters(in: CharacterSet.whitespaces)
    if remainText.count > 0 {
        return false
    }
    return true
}
生成标题属性文本
private func generateTitleAttributeString() -> NSAttributedString? {
    guard let text = title, !isEmptyString(text: text) else { return nil }
    
    let paraStyle = NSMutableParagraphStyle()
    paraStyle.alignment = .center
    return NSAttributedString.init(string: text, attributes: [NSAttributedString.Key.font: MLAlertView.kBoldTextFont, NSAttributedString.Key.paragraphStyle: paraStyle])
}
计算属性文本的高度
private func caculateAttributeStringHeight(text: NSAttributedString) -> CGFloat {
    let constraintSize = CGSize(width: MLAlertView.kConstraintContainerWidth - MLAlertView.kSplitLineDefaultHorizeSpace * 2, height: MLAlertView.kScreenHeight)
    let height = text.boundingRect(with: constraintSize, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil).size.height
    return height
}

三、多行输入框

在上面创建提示弹窗的过程中,我们使用到了一个自定义的输入框MLTextView,下面我们来看看其是如何实现的。首先看看我嘛提供给外界修改的属性:

open class MLTextView: UITextView {
    /// 占位文本的x和y位置
    open var placeholderX : CGFloat = 5
    open var placeholderY : CGFloat = 8
    /// 是否移除emoji,默认为false
    open var isRemoveEmoji: Bool = false
    /// 最大限制文本长度,默认不限制长度
    open var maxLength: Int = LONG_MAX
    /// 设定文本改变回调
    open var didValueChange: (UITextView) -> Void = { _ in }
    /// 设定文本达到最大长度的回调
    open var didMaxLength: (UITextView) -> Void = { _ in }
    /// 占位文字
    open var placeholder: String = ""
    /// 占位文字体大小
    open var placeholderFont: UIFont = .systemFont(ofSize: 14)
    /// 占位文字颜色
    open var placeholderColor: UIColor = .gray
}

可以看到在上面的属性中提供了修改占位符的方式,那么我们如何实现修改文本框的占位符呢?在draw方法中我们可以通过hasText判断当前用户是否输入了文本。倘若输入就可以使用上面的占位符属性去创建富文本,接着调整占位符的位置,并将其使用该位置和属性进行绘制。

open override func draw(_ rect: CGRect) {
    guard !hasText else { return }
    
    var rect = rect
    let attributedString: [NSAttributedString.Key : Any] = [NSAttributedString.Key.font: placeholderFont, NSAttributedString.Key.foregroundColor: placeholderColor]
    rect.origin.x = placeholderX
    rect.origin.y = placeholderY
    rect.size.width -= 2 * rect.origin.x
    placeholder.draw(in: rect, withAttributes: attributedString)
}

我们想要监听文本的变化,那么就需要注册通知来监听文本变化。

NotificationCenter.default.addObserver(self, selector: #selector(textViewDidChange(notification:)), name: NSNotification.Name.UITextViewTextDidChange, object: nil)

这样当文本发生改变的时候我们就会来到textViewDidChange方法,在该方法之中我们可以对输入的文本进行处理。这里我们禁止第一个字符输入空格或者换行,并且判断是否可以输入表情包,接着判断是否达到了最大输入的字符个数,最后调用输入完成回调。

@objc private func textViewDidChange(notification: Notification) {
    setNeedsDisplay()

    if text.count == 1, text == " " || text == "\n" {
        text = ""
    }
    
    if isRemoveEmoji {
        text = text.text_removeEmoji()
    }
    
    if maxLength != LONG_MAX,
       text.count > maxLength {
        didMaxLength(self)
        text = text.prefix(maxLength).description
    }
    
    didValueChange(self)
}

注意掉上面我们调用了一个setNeedsDisplay方法进行重绘,倘若不调用该方法,那么在我们输入的时候,占位文本仍然存在,并没有重新绘制。


四、启动引导页

在我们首次启动APP的时候总是会有一段引导用户的界面,介绍了这款APP可以给用户带来哪些帮助,让用户第一时间知道如何去使用。这一次我们就来探索一下其是如何实现的。

1、如何在AppDelegate中创建启动引导页

启动引导页是在AppDelegate中当APP启动的时候创建的,在创建的过程中我们需要判断版本号,因为通过版本号我们才能判断用户是否是第一次使用我们的APP或者APP版本已经更新了这两种情况我们就需要弹出引导页来提示用户。首先我们需要得到当前应用的版本号,接着需要取出之前保存的版本号,如果 appVersionnil 说明是第一次启动,而如果 appVersion 不等于 currentAppVersion 说明是更新了,这时候我们就需要保存最新的版本号,再将新创建的引导页控制器作为window的根控制器,且引导页铺满了整个屏幕,倘若不是这两种情况的话,那么就直接将MLHomeTabBarControllerTab作为根控制器。

let guideController = UIViewController()
guideController.view.frame = (window?.frame)!

// 得到当前应用的版本号
let infoDictionary = Bundle.main.infoDictionary
let currentAppVersion = infoDictionary!["CFBundleShortVersionString"] as! String
// 取出之前保存的版本号
let userDefaults = UserDefaults.standard
let appVersion = userDefaults.string(forKey: "appVersion")

if appVersion == nil || appVersion != currentAppVersion {
    // 保存最新的版本号
    userDefaults.setValue(currentAppVersion, forKey: "appVersion")
    ...
    window?.rootViewController = guideController
    window?.makeKeyAndVisible()
} else {
    setingWindowRootVC()
}

引导页控制器guideController的子视图就是我们接下来要创建的GuidePageView,在创建的时候只需要传入引导页图片数组即可,由于我们支持右滑引导页图片,所以我们将其设置为了isSlipIntoHomeViewtrue。最后当点击跳过或者立即体验的时候就在完成回调中将MLHomeTabBarControllerTab作为根控制器。

let imageArray = ["guide_image1","guide_image2","guide_image3"]
let guideView = MLGuidePageView.init(images: imageArray) { [weak self] in
    self?.setingWindowRootVC()
}
guideView.isSlipIntoHomeView = true
guideController.view.addSubview(guideView)

接下来我们来到创建App启动引导页的初始化方法之中。我们传入了frame用来设置引导页大小,默认是全屏幕。images是引导页图片数组,可以传入gif/png/jpeg...等类型,注意gif图不可放在Assets中否则加载不出来,建议引导页的图片都不要放在Assets文件中,因为使用imageName加载时,系统会缓存图片,造成内存暴增。isHiddenSkipButton表示是否隐藏跳过按钮,isHiddenStartButton表示是否隐藏立即体验按钮。startCompletion是点击立即体验的回调。

private override init(frame: CGRect) {
    super.init(frame: frame)
}
public convenience init(frame: CGRect = UIScreen.main.bounds,
                        images: Array<String>,
                        isSlipIntoHomeView: Bool = false,
                        isHiddenSkipButton: Bool = false,
                        isHiddenStartButton: Bool = false,
                        startCompletion: (() -> ())?) {
    self.init(frame: frame)
    backgroundColor = .white
    
    self.imageArray = images
    self.isSlipIntoHomeView = isSlipIntoHomeView
    self.isHiddenSkipButton = isHiddenSkipButton
    self.isHiddenStartButton = isHiddenStartButton
    self.startCompletion = startCompletion
    
    createSubviews(frame: frame)
}

2、启动引导页由哪些视图元素构成

整个启动引导页支持左右滑动,所以整体来说是一个滚动视图UIScrollView

lazy var guideScrollView: UIScrollView = {// 引导滚动视图
    let view = UIScrollView()
    view.backgroundColor = .clear
    view.bounces = false
    view.isPagingEnabled = true
    view.showsHorizontalScrollIndicator = false
    view.delegate = self
    return view
}()

其大小为我们传入的frame,默认为整个屏幕,内容区域为图片数量 * frame的宽度。

private func createSubviews(frame: CGRect) {
    guideScrollView.frame = frame
    guideScrollView.contentSize = CGSize(width: frame.size.width * CGFloat(imageArray.count), height: frame.size.height)
    addSubview(guideScrollView)
    ...
}

接下来我们创建了跳过按钮,其位于引导页的右上方角落,当点击它的时候就可以直接进入到APP真正的界面中去,比如说登录界面。

private lazy var skipButton: UIButton = {// 跳过按钮
    let button = UIButton(type: .custom)
    button.frame = CGRect(x: kScreenWidth - MLFit.fitFloat(70) , y: 44, width: MLFit.fitFloat(50), height: MLFit.fitFloat(24))
    button.isHidden = isHiddenSkipButton
    button.backgroundColor = .ml_hex( 0x000000,alpha:0.4)
    button.layer.cornerRadius = 5.0
    button.layer.masksToBounds = true
    button.setTitle("跳 过", for: .normal)
    button.titleLabel?.font = .systemFont(ofSize: 12).fitFont
    button.setTitleColor(.white, for: .normal)
    button.titleLabel?.sizeToFit()
    button.addTarget(self, action: #selector(startExperience), for: .touchUpInside)
    return button
}()

可以看到上面视图中的分页控件其样式完全是由我们来自定义的,我们创建了一个MLGuidePageControl视图,在其中提供了一系列自定义控件样式的属性,如下所示:

class MLGuidePageControl: UIView {
    var currentDotWidth: CGFloat = 0// 当前页面控件宽度
    var otherDotWidth: CGFloat = 0// 其他页面控件宽度
    var dotHeight: CGFloat = 0// 控件高度
    var dotSpace: CGFloat = 0// 控件间距
    var corner_Radius: CGFloat = 0// 控件圆角大小
    var currentDotColor: UIColor = .red// 当前页面控件颜色
    var otherDotColor: UIColor = .lightGray// 其他页面控件颜色
    ...
}
private lazy var guidePageControl: MLGuidePageControl = {// 分页控件
    let page_x = (kScreenWidth - MLFit.fitFloat(14) * CGFloat(imageArray.count))/2
    let page_width = MLFit.fitFloat(14) * CGFloat(imageArray.count)
    let pageControlHeight: CGFloat = MLFit.fitFloat(4)
    let page_y = kScreenHeight - pageControlHeight - MLFit.fitFloat(54) - UIScreen.ml_safeAreaEdgeInsets.bottom
    
    let pageControl = MLGuidePageControl(frame:CGRect(x: page_x, y: page_y, width: page_width, height: pageControlHeight))
    pageControl.backgroundColor = .ml_hex(0xEBEDF0)
    pageControl.corner_Radius = pageControlHeight/2
    pageControl.dotHeight = pageControlHeight
    pageControl.dotSpace = 0
    pageControl.currentDotWidth = MLFit.fitFloat(14)
    pageControl.otherDotWidth = MLFit.fitFloat(14)
    pageControl.otherDotColor = .ml_hex( 0xEBEDF0)
    pageControl.currentDotColor = .ml_hex( 0x187CFF)
    pageControl.numberOfPages = imageArray.count
    pageControl.layer.cornerRadius = pageControlHeight/2
    return pageControl
}()

其中numberOfPages属性表示分页控件的个数,当我们设置该属性的时候就会去创建相应个数个控件视图再将其放置在dotViewArray数组中。在创建新的之前我们首先会将旧的视图移除掉,接着我们需要计算出每个控件的布局,其高度和y坐标保持一致,我们只需要计算出其x坐标即可,而其宽度需要根据是否是当前页面来进行区分处理。

private var dotViewArray = [UIView]()// 控件视图数组

var numberOfPages: Int = 0 {// 控件个数
    didSet {
        dotViewArray.forEach { $0.removeFromSuperview() }
        dotViewArray.removeAll()
        
        var dotX: CGFloat = 0
        for index in 0 ..< numberOfPages {
            let dotView = UIView()
            addSubview(dotView)
            dotViewArray.append(dotView)
            
            dotView.layer.cornerRadius = corner_Radius
            dotView.backgroundColor = (index == currentPage ? currentDotColor : otherDotColor)
            let dotWidth = (index == currentPage ? currentDotWidth : otherDotWidth)
            dotView.frame = CGRect(x: dotX, y: 0, width: dotWidth, height: dotHeight)
            dotX = dotX + dotWidth + dotSpace
        }
    }
}

接下来我们需要创建引导页展示的图片视图,这里遍历数组用传入的每个图片名称去获取图片资源进行创建,倘若是最后一张图片的话,那么还需要在该图片上添加一个立即体验按钮,让其可点击。

guard imageArray.count > 0 else { return }
for index in 0 ..< imageArray.count {
    let imageName = imageArray[index]
    let imageView = createImageView(imageName: imageName, index: index)
    guideScrollView.addSubview(imageView)
    
    if imageName == imageArray.last  {
        imageView.isUserInteractionEnabled = true
        if !isHiddenStartButton {
            imageView.addSubview(startButton)
        }
    }
}

在利用图片名称去获取图片资源的时候,我们需要判断图片类型,这里根据Data类型的特点来判断了传入究竟是哪种类型的图片,倘若是GIF动图的话就需要进行特殊处理。假如不是GIF图片的话,也有需要注意的地方,假设图片是放在Assets中的,那么使用Bundle的方式是加载不到图片资源的,需要使用init(named:)方法来加载。

private enum ImageType: String {
    case gif = "gif"
    case png = "png"
    case jpeg = "jpeg"
    case tiff = "tiff"
    case defaultType
}
private func checkDataType(data: Data?) -> ImageType {
    guard data != nil else { return .defaultType }
    let c = data![0]
    switch (c) {
    case 0xFF:
        return .jpeg
    case 0x89:
        return .png
    case 0x47:
        return .gif
    case 0x49, 0x4D:
        return .tiff
    default:
        return .defaultType
    }
}
private func createImageView(imageName: String, index: Int) -> UIView {
    let imageFilePath = Bundle.main.path(forResource: imageName, ofType: nil) ?? ""
    let imageData: Data? = try? Data.init(contentsOf: URL.init(fileURLWithPath: imageFilePath), options: Data.ReadingOptions.uncached)
    let type: ImageType = checkDataType(data: imageData)
    
    var imageView: UIView
    let imageFrame = CGRect(x: kScreenWidth * CGFloat(index), y: 0.0, width: kScreenWidth, height: kScreenHeight)
    if type == .gif {
        imageView = MLGuideGifImageView(frame: imageFrame, gifData: imageData!)
    } else {
        imageView = UIImageView(frame: imageFrame)
        imageView.contentMode = .scaleAspectFit
        imageView.clipsToBounds = true
        (imageView as! UIImageView).image = (imageData != nil ? UIImage(data: imageData!) : UIImage(named: imageName)?.withRenderingMode(.alwaysOriginal))
    }
    
    return imageView
}

在创建完成图片视图之后,针对最后一张图片,我们需要添加立即体验按钮,注意这个按钮在只有一张图片的时候是会隐藏掉的。

public lazy var startButton: UIButton = {// 立即体验按钮
    let button = UIButton.init(type: .custom)
    button.setTitle("立即体验", for: .normal)
    button.backgroundColor = .ml_hex(0x187CFF)
    button.titleLabel?.font = UIFont.systemFont(ofSize: 17).fitFont
    button.setTitleColor(.white, for: .normal)
    button.titleLabel?.sizeToFit()
    button.alpha = imageArray.count == 1 ? 1.0 : 0.0
    button.layer.cornerRadius = MLFit.fitFloat(20)
    button.addTarget(self, action: #selector(startExperience), for: .touchUpInside)
    
    let space: CGFloat = MLFit.fitFloat(80)
    let pageControlHeight: CGFloat = MLFit.fitFloat(4)
    let buttonHeight = MLFit.fitFloat(40)
    let buttonWidth = MLFit.fitFloat(197)
    let buttonY = kScreenHeight - UIScreen.ml_safeAreaEdgeInsets.bottom - buttonHeight - pageControlHeight - space
    let buttonX = (kScreenWidth - buttonWidth) * 0.5
    button.frame = CGRect(x: buttonX, y: buttonY, width: buttonWidth, height: buttonHeight)

    return button
}()

综上,我们创建引导页视图元素的整体流程如下:

private func createSubviews(frame: CGRect) {
    guideScrollView.frame = frame
    guideScrollView.contentSize = CGSize(width: frame.size.width * CGFloat(imageArray.count), height: frame.size.height)
    addSubview(guideScrollView)
    addSubview(skipButton)
    addSubview(guidePageControl)

    guard imageArray.count > 0 else { return }
    for index in 0 ..< imageArray.count {
        let imageName = imageArray[index]
        let imageView = createImageView(imageName: imageName, index: index)
        guideScrollView.addSubview(imageView)
        
        if imageName == imageArray.last  {
            imageView.isUserInteractionEnabled = true
            if !isHiddenStartButton {
                imageView.addSubview(startButton)
            }
        }
    }
}

3、引导页的点击事件会触发什么效果

点击跳过按钮或者立即体验按钮,都调用下面这个方法,立即退出引导页,同时也会触发立即体验的回调。

@objc private func startExperience() {
    if self.startCompletion != nil {
        self.startCompletion!()
    }
    self.removeGuideViewFromSupview()
}

退出引导页的过程有一个浅浅消失的动画,当动画完成之后再将引导页从父视图中移除掉。

private func removeGuideViewFromSupview() {
    UIView.animate(withDuration: 1.0, delay: 0, options: .curveEaseOut, animations: {
        self.alpha = 0.0
    }) { (_) in
        self.removeFromSuperview()
    }
}

接下里我们看一下如何让指示器进行切换,在滚动即将结束的时候我们计算出当前所在页面,然后就设置指示器的位置。倘若最后一页不隐藏立即体验按钮的话,那么就将这个按钮用动画的方式渐渐展示出来。

public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    let page: Int = Int(scrollView.contentOffset.x / scrollView.bounds.size.width)
    guidePageControl.currentPage = page

    if !isHiddenStartButton, imageArray.count - 1 == page {
        UIView.animate(withDuration: 1.0, animations: {
            self.startButton.alpha  = 1.0
        })
    }
}

这里有个特殊的逻辑需要处理。有一种在最后一个展示立即体验按钮的页面右滑直接进入到主题界面的方式,是否支持这种方式,我们根据isSlipIntoHomeView来进行判断,倘若支持的话,我们就需要在contentSize上再额外增加50点的距离,倘若无这个额外的距离的话,会发现滑动了最后一页后就无法向右滑动了,也就无法进入到scrollViewDidScroll方法之中。

let contentWidth = frame.size.width * CGFloat(imageArray.count) + (isSlipIntoHomeView ? CGFloat(50) : 0)
guideScrollView.contentSize = CGSize(width: contentWidth, height: frame.size.height)

scrollViewDidScroll方法之中我们将滑动值和总值进行了相减,倘若差距大于30那么就进入到直接体验到逻辑中。

public func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard isSlipIntoHomeView && imageArray.count > 0 else { return }
    
    let totalWidth = kScreenWidth * CGFloat(imageArray.count - 1)
    let offsetX = scrollView.contentOffset.x - totalWidth
    if offsetX > 30 {
        self.startExperience()
    }
}

4、如何实现自定义的指示器视图

可以看到在立即体验按钮的下方我们展示了一个自定义的切换页面的指示器横条,在左右滑动切换页面的时候该指示器也会随之发生改变,我们在前面的步骤中使用到了该指示器视图提供的一些自定义属性。现在我们来看下其是如何实现的,首先我们来到最关键的控制切换页面的属性currentPage中。我们为其实现了getset方法,在get方法中获取到当前所在页面位置。在set方法中我们首先进行了一系列判断,必须保证滚动到的页面在边界范围之内,且和之前的页面不同,并且当前并没有处于动画状态,这样才能产生左右滑动的动画效果。

private var currentPageInner: Int = 0// 内部的当前指示器所在页面
private var inAnimating: Bool = false// 是否正处于动画之中
var currentPage: Int {// 提供给外界切换到当前页面
    get {
        currentPageInner
    }
    set {
        if (newValue < 0 ||  newValue >= dotViewArray.count  || dotViewArray.count == 0 || newValue == currentPage || inAnimating) { return }
        
        if (newValue > currentPage) {// 向右滑动
            slideAnimations(newValue: newValue, direction: .right)
        } else {// 向左滑动
            slideAnimations(newValue: newValue, direction: .left)
        }
    }
}

用于实现左右滑动动画的slideAnimations方法,我们来看下其是如何实现的,首先我们会计算出接下来动画过程中会使用到的一些公共值,比如高度保持不变,宽度都会拓展相同值。

private func slideAnimations(newValue: Int, direction: SlideDirection) {
    inAnimating = true
    let currentView = dotViewArray[currentPage]
    bringSubviewToFront(currentView)
    
    let dotY = currentView.frame.origin.y
    let extendWidth = self.currentDotWidth + self.dotSpace + self.otherDotWidth
    var slideX: CGFloat = 0
    if direction == .right {
        slideX = currentView.frame.origin.x
    } else {
        slideX = currentView.frame.origin.x - self.dotSpace - self.otherDotWidth
    }
    ...
}

这里的动画效果是这样的,以右滑从第二个页面到第三个页面为例,首先第二个页面的指示器其x横坐标保持不变,宽度增加一个圆点的宽度和间隙,这样就会把要滑动到的视图给覆盖掉,这时候再将第二个页面的指示器复原宽度为非活跃指数器的宽度,将第三个页面的指示器的x`横坐标计算出来,为最大宽度减去活跃指示器的宽度,而其宽度就是活跃指示器的宽度。向左滑动也是同理,只是计算的值不同而已。

UIView.animate(withDuration: 0.0, animations: {
    currentView.frame = CGRect(x: slideX, y: dotY, width: extendWidth, height: self.dotHeight)
}) { (finished) in
    let endView = self.dotViewArray[newValue]
    endView.backgroundColor = currentView.backgroundColor
    endView.frame = currentView.frame
    currentView.backgroundColor = self.otherDotColor
    self.bringSubviewToFront(endView)
    
    if direction == .right {
        currentView.frame = CGRect(x: slideX, y: dotY, width: self.otherDotWidth, height: self.dotHeight)
    } else {
        let currentViewViewX = currentView.frame.maxX - self.otherDotWidth
        currentView.frame = CGRect(x: currentViewViewX, y: dotY, width: self.otherDotWidth, height: self.dotHeight)
    }

    UIView.animate(withDuration: 0.1, animations: {
        if direction == .right {
            let endViewX = endView.frame.maxX - self.currentDotWidth
            endView.frame = CGRect(x: endViewX, y: dotY, width: self.currentDotWidth, height: self.dotHeight)
        } else {
            endView.frame = CGRect(x: slideX, y: dotY, width: self.currentDotWidth, height: self.dotHeight)
        }
    }) { (finished) in
        self.currentPageInner = newValue;
        self.inAnimating = false;
    }
}

5、引导页中如何实现播放GIF动画

上图中在引导页中放入了一个GIF的钟表视图,接下来我们看看如何这个效果的。我们自定义了一个MLGuideGifImageView视图,在其初始化方法中传入了GIF图片数据,其大小默认为整个屏幕大小。

class MLGuideGifImageView: UIView {
    private var gifTimer: DispatchSourceTimer?// GIF播放定时器
    
    /// 播放gif图片
    /// - Parameters:
    ///   - frame: 显示大小
    ///   - gifData: gif数据
    public convenience init(frame: CGRect = UIScreen.main.bounds, gifData: Data) {
        self.init(frame: frame)
        ...
    }

}

接着我们从GIF图片数据获取到了gif总帧数、gif图片组、gif播放时长这些信息。其中gif播放时长是通过获取到gif每帧时间间隔将其进行累加计算到的,gif图片组是将获取到的每帧图片依次放入到数组中获取得到的。

let gifProperties  = NSDictionary(object: NSDictionary(object: NSNumber(value: 0), forKey: kCGImagePropertyGIFLoopCount as! NSCopying), forKey: kCGImagePropertyGIFDictionary as! NSCopying)
guard let gifDataSource  = CGImageSourceCreateWithData(gifData  as CFData, gifProperties) else {
    return
}

let gifImagesCount = CGImageSourceGetCount(gifDataSource)// gif总帧数
var images = [UIImage]()// gif图片组
var gifDuration = 0.0// gif播放时长
for i in 0 ..< gifImagesCount {
    guard let imageRef = CGImageSourceCreateImageAtIndex(gifDataSource, i, gifProperties) else { return }

    if gifImagesCount == 1 {// 单帧
        gifDuration = Double.infinity
    } else {// 获取到gif每帧时间间隔
        guard let properties = CGImageSourceCopyPropertiesAtIndex(gifDataSource, i, nil),
            let gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary,
            let frameDuration = (gifInfo[kCGImagePropertyGIFDelayTime as String] as? NSNumber) else {
            return
        }
        gifDuration += frameDuration.doubleValue
        let image = UIImage.init(cgImage: imageRef, scale: UIScreen.main.scale, orientation: UIImage.Orientation.up)
        images.append(image)
    }
}

接下来我们根据总时长和总帧数,计算平均播放时长。这里根据业务给的规则,我们会对平均播放时长的数值进行调整。

var repeating = gifDuration / Double(gifImagesCount)
// 规则一: 如果平均时长超过1.2秒, 按0.1计算
repeating = repeating > 1.2 ? 0.1 : repeating
// 规则二: 如果总帧数超过30并且时长超过0.06,按0.06计算
repeating = (gifImagesCount > 30 && repeating > 0.06) ? 0.06 : repeating
// 规则三: 如果总帧数超过50并且时长超过0.04,按0.04计算
repeating = (gifImagesCount > 50 && repeating > 0.04) ? 0.04 : repeating

最后启动计时器,按照给定的平均播放时长重复调用传入不同帧的CGImage给当前视图的layer.contents,让其动起来。

gifTimer = DispatchSource.makeTimerSource(flags: [], queue: DispatchQueue.global())
gifTimer?.schedule(deadline: .now(), repeating: repeating)
var index = 0
gifTimer?.setEventHandler(handler: { [weak self] in
    DispatchQueue.main.async {
        index = index % gifImagesCount
        let imageref: CGImage? = CGImageSourceCreateImageAtIndex(gifDataSource, index, nil)
        self?.layer.contents = imageref
        index += 1
    }
})
gifTimer?.resume()
上一篇下一篇

猜你喜欢

热点阅读