SwiftUI:刷新组件RefreshUI之桥接UIKit

在这篇文章中我们将把下拉刷新和上拉加载功能合并到一个自定义组件RefreshUI。在UIKit中UIScrollView自带refreshControl下拉刷新功能。我们只需要在合适的时机做一下状态处理即可,具体可参看SwiftUI:List下拉刷新之桥接UIKit
但是上拉加载就需要我们自己去处理了,在一些第三方的刷新组件中,下拉刷新和上拉加载功能都是采用自定义的方式。我们在这里参考三方组件中上拉加载功能的一些思路,做一个简单的上拉加载功能。

最终效果如下:

loadmore.png

设计理想的调用方式

在使用第三方组件的时候,用起来是很方便的。为了让我们的SwiftUI也可以使用简单方便的api,我们想要最终的调用方式如下:

 List(array, id: \.self) { text in
      Text(text).padding()
}
.addRefreshHeader(isRefreshing: $isRefreshing)
.addRefreshFooter(isLoadingMore: $isLoadingMore,
                  noMoreText:page >= totalPage ? "没有更多数据了":nil)

使用场景

刷新一般都是配合网络请求一起使用的。当List数据量比较多的时候我们一般会采用分页的方式批量获取数据,然后再进行渲染操作。在这里我们就模拟一下网络请求的方式,记录当前的分页page,和获取的总页数totalPage。模型Model代码如下:

class ModelObject: ObservableObject {
    @Published var isRefreshing: Bool = false {
        didSet {
            if isRefreshing {
                self.page = 1
                //刷新发起网络请求
                requestData(self.page)
            }
        }
    }
    @Published var isLoadingMore: Bool = false {
        didSet {
            if isLoadingMore {
                if self.page < totalPage {
                    self.page += 1
                }else{
                    self.isLoadingMore = false
                    return
                }
                //刷新发起网络请求
                requestData(self.page)
            }
        }
    }
    
    @Published var array:[String] = []
    
    var page:Int = 1
    let totalPage: Int = 3
    
    func requestData(_ page:Int = 1) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            if page == 1 {
                self.array = Array(0...15).map { "Hello, world! - \($0) - \(page)" }
            }else {
                let items = Array(0...15).map { "Hello, world! - \($0) - \(page)" }
                self.array.append(contentsOf: items)
            }
            self.isRefreshing = false
            self.isLoadingMore = false
        }
    }
}

在这里我们同时订阅了isRefreshing isLoadingMorearray,当它们发生变化时,将会触发网络请求和界面渲染。

SwiftUI界面调用就变得简单了:

struct ListView: View {
    @ObservedObject var modelObject = ModelObject()
    
    init() {
        modelObject.requestData()
    }
    
    var body: some View {
        NavigationView {
            List(modelObject.array, id: \.self) { text in
                Text(text).padding()
          }
          .addRefreshHeader(isRefreshing: $modelObject.isRefreshing)
          .addRefreshFooter(isLoadingMore: $modelObject.isLoadingMore,
                            noMoreText:modelObject.page >= modelObject.totalPage ? "没有更多数据了":nil)
          .navigationBarTitle("刷新demo")
        }
    }
}

自定义修饰符

下面我们看下addRefreshHeaderaddRefreshFooter修饰符,这其实就是对View视图的一个扩展:

extension View {
    func addRefreshHeader(isRefreshing: Binding<Bool>,
                          action: (() -> Void)? = nil) -> some View {
        
        self.background(RefreshUI(style: .header, isRefreshing: isRefreshing, action: action))
        
      }
    
    func addRefreshFooter(isLoadingMore: Binding<Bool>,
                          noMoreText:String?,
                          action: (() -> Void)? = nil) -> some View {
        
        self.background(RefreshUI(style: .footer,
                                  isRefreshing: isLoadingMore,
                                  noMoreText: noMoreText,
                                  action: action))
        
      }
}

从上面的扩展方法我们可以看到,其实最核心的部分是我们的自定义组件RefreshUI

RefreshUI

  • 我们给它设置了style类型,定义是下拉刷新还是上拉加载:
extension RefreshUI {
    public enum Style {
        case header, footer
    }
}

在这里我们给RefreshUI扩展了一个枚举类型。

  • 参数isRefreshing用来控制状态的改变,我们使用Binding<Bool>来修饰。
  • action当状态发生改变时的回调方法
  • noMoreTextstyle类型为footer时,数据全部加载完的提示消息

完整的代码如下:

//刷新组件
struct RefreshUI: UIViewRepresentable {
    @Binding var isRefreshing: Bool
    let action: (() -> Void)?
    let noMoreText:String?
    private let style: Style
    
    init(style: Style, isRefreshing: Binding<Bool>,
         noMoreText:String? = nil,
         action: (() -> Void)? = nil) {
        self.style = style
        _isRefreshing = isRefreshing
        self.noMoreText = noMoreText
        self.action = action
    }
    
    func makeUIView(context: Context) -> some UIView {
        let uiView = UIView(frame: .zero)
        return uiView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        DispatchQueue.main.asyncAfter(deadline: .now()) {
            guard let viewHost = uiView.superview?.superview else {
                return
            }
            guard let scrollView = self.scrollView(root: viewHost) else {
                return
            }
            if style == .header {
                if let refreshControl = scrollView.refreshControl {
                    context.coordinator.resetFooter(scrollView)
                    if self.isRefreshing {
                        refreshControl.beginRefreshing()
                    } else {
                        refreshControl.endRefreshing()
                    }
                    
                }else {
                    let refreshControl = UIRefreshControl()
                    scrollView.refreshControl = refreshControl
                    context.coordinator.setupObserver(scrollView)
                }
            }else if style == .footer {
                context.coordinator.noMoreText = noMoreText
                context.coordinator.setupObserver(scrollView)
            }
            
            
        }
    }
    
    static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
        coordinator.clearObserver()
    }
    
}

Coordinator创建和处理部分则把他们放在了RefreshUI的扩展部分:

extension RefreshUI {
    func makeCoordinator() -> Coordinator {
        return Coordinator(style, isRefreshing: $isRefreshing,
                           noMoreText:noMoreText,
                           action:action)
    }
    
    class Coordinator {
        let style: Style
        var isRefreshing: Binding<Bool>
        var noMoreText:String?
        let action: (() -> Void)?
        private var offsetToken: NSKeyValueObservation?
        private var stateToken: NSKeyValueObservation?
        private var sizeToken: NSKeyValueObservation?
        private var initOffset:CGFloat = 0
        private var initInsetBottom:CGFloat = 0
        private let height:CGFloat = 60
        private var progress: CGFloat = 0
        private var footerView:FooterView?
        
        init(_ style:Style, isRefreshing: Binding<Bool>,
             noMoreText:String?,
             action: (() -> Void)?) {
            self.style = style
            self.isRefreshing = isRefreshing
            self.noMoreText = noMoreText
            self.action = action
        }
        
    }
    
    private func scrollView(root: UIView) -> UIScrollView? {
        for subview in root.subviews {
            if subview.isKind(of: UIScrollView.self) {
                return subview as? UIScrollView
            } else if let scrollView = scrollView(root: subview) {
                return scrollView
            }
        }
        return nil
    }
    
}

交互处理逻辑部分也单独做了扩展:

private extension RefreshUI.Coordinator {
    func resetFooter(_ scrollView: UIScrollView) {
        if style == .header {
            if initInsetBottom != scrollView.contentInset.bottom, scrollView.contentOffset.y <= initOffset - height {
                scrollView.contentInset.bottom = initInsetBottom
            }
        }
        
    }
    
    func setupFooterView(_ scrollView: UIScrollView) {
        if style == .footer{
            if footerView == nil  {
                footerView = FooterView(frame:.zero)
            }
            footerView?.loadingText = noMoreText
            if noMoreText != nil && !isRefreshing.wrappedValue {
                scrollView.contentInset.bottom = initInsetBottom + self.height
            }
            
            if footerView?.isRefreshing != isRefreshing.wrappedValue && !isRefreshing.wrappedValue {
                DispatchQueue.main.async {
                    UIView.animate(withDuration: 0.3, animations: {
                        if self.noMoreText == nil {
                            scrollView.contentInset.bottom -= self.height
                        }
                        
                    }, completion: { _ in
                        
                        self.progress = 0
                        
                    })
                }
                
            }
            //isRefreshing 状态发生变化则重新赋值
            footerView?.isRefreshing = isRefreshing.wrappedValue
            
        }
        
    }

    func setupObserver(_ scrollView: UIScrollView) {
        
        setupFooterView(scrollView)
        
        if (offsetToken != nil) {
            return;
        }
        offsetToken = scrollView.observe(\.contentOffset) { [weak self] scrollView, _ in
            self?.scrollViewDidScroll(scrollView)
        }
        
        stateToken = scrollView.observe(\.panGestureRecognizer.state) {
            [weak self] scrollView,_  in
            
            guard scrollView.panGestureRecognizer.state == .ended else { return }
            
            self?.scrollViewDidEndDragging(scrollView)
        }
        
        initInsetBottom = scrollView.contentInset.bottom
        
        if style == .header {
            initOffset = scrollView.contentOffset.y
            
        }else {
            guard let footerView = footerView  else {
                return
            }
            scrollView.insertSubview(footerView, at: 0)
            sizeToken = scrollView.observe(\.contentSize) { [weak self] scrollView, _ in
                footerView.frame = CGRect(x: 0, y: scrollView.contentSize.height, width: UIScreen.main.bounds.width, height: self?.height ?? 0)
                footerView.isHidden = scrollView.contentSize.height <= scrollView.bounds.height && self?.noMoreText == nil
                
            }
        }
        
    }
    func clearObserver() {
        offsetToken?.invalidate()
        stateToken?.invalidate()
        sizeToken?.invalidate()
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if isRefreshing.wrappedValue { return }
        if style == .footer {
            if scrollView.contentSize.height > scrollView.bounds.height {
                progress = min(1, max(0, (scrollView.contentOffset.y + scrollView.bounds.height - scrollView.contentSize.height - scrollView.contentInsetBottom) / height))
            }
           
        }
        
    }
    
    func scrollViewDidEndDragging(_ scrollView: UIScrollView) {
        if isRefreshing.wrappedValue { return }
        
        switch style {
        case .header:
            if initOffset - scrollView.contentOffset.y < 40 {
                return
            }
        case .footer:
            if progress < 1 { return }
            if noMoreText != nil { return }
            progress = 1
            scrollView.contentInset.bottom += self.height
        }
        
        isRefreshing.wrappedValue = true
        if let actionMethod = action {
            actionMethod()
        }
        
    }
    
}

private extension UIScrollView {
    var contentInsetTop: CGFloat {
        if #available(iOS 11.0, *) {
            return contentInset.top + adjustedContentInset.top
        } else {
            return contentInset.top
        }
    }

    var contentInsetBottom: CGFloat {
        if #available(iOS 11.0, *) {
            return contentInset.bottom + adjustedContentInset.bottom
        } else {
            return contentInset.bottom
        }
    }
}

使用UIKit自定义的FooterView:

private class FooterView: UIView {
    var isRefreshing = false {
        didSet {
            if isRefreshing {
                indicator.startAnimating()
                label.isHidden = isRefreshing
            }else {
                indicator.stopAnimating()
                label.isHidden = loadingText == nil
            }
        }
    }
    
    let indicator = UIActivityIndicatorView(style: .medium)
    var loadingText: String? = nil {
        didSet {
            
            label.text = loadingText
            label.sizeToFit()
        }
    }

    private lazy var label: UILabel = {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 14)
        label.textColor = UIColor.black.withAlphaComponent(0.8)

        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(indicator)
        addSubview(label)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        label.center = CGPoint(x: bounds.midX, y: bounds.midY)
        indicator.center = CGPoint(x: bounds.midX, y: bounds.midY)
    }

}

最终运行效果如下:

refreshUI.gif

总结

refreshUI只实现了最为基础的功能,相对于比较成熟的第三方框架来说还是不够完善的。比如如何设置自定义效果,如何设置下拉或加载的自定义视图GIF效果等。所以如果要获得更加强大的功能,可以对第三方框架二次封装转换API给SwiftUI使用。
这里仍然有个小缺陷,就是在引入自定义的UIKit中的class类类型视图时,使用Preview无法实时预览效果,但功能还是正常的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容