SwiftUI:List下拉刷新之桥接UIKit

下拉刷新是我们在应用中经常用到的一个功能,但是SwiftUI并没有提供类似的api或视图修饰符。
在这篇文章中我们将利用UIKit已有的功能桥接封装提供给SwiftUI的List组件使用。

UIViewRepresentable

UIViewRepresentable就是SwiftUI和UIKit中的一个桥梁,系统会把SwiftUI中的View翻译成UIKit中的View,从而获取更加强大的功能。

UIViewRepresentable定义如下:

public protocol UIViewRepresentable : View where Self.Body == Never {
    associatedtype UIViewType : UIView
    
    //初始化配置UIView类型视图状态并将其返回,context为当前环境上下文。
    //该方法只会在创建的时候执行一次
  func makeUIView(context: Self.Context) -> Self.UIViewType
  
  //使用SwiftUI传递的新信息更新指定视图的属性状态。
  func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)
  
  //在UIKit视图被移除之前清调用,用于视图额外的清理工作。例如删除观察者
  static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

    associatedtype Coordinator = Void
    
    //创建自定义实例,用于将视图的变量绑定到SwiftUI属性,使两者保持同步。如果没有交互则不必实现该方法
    func makeCoordinator() -> Self.Coordinator

   typealias Context = UIViewRepresentableContext<Self>
}

我们自定义一个桥接组件BridgeView,实现UIViewRepresentable相关协议方法,并在makeUIView方法中使用UIKit框架中的组件初始化一个UILabel,然后在swiftUI中使用:

struct ContentView: View {
    var body: some View {
        BridgeView()
            .frame(height: 100)
            .padding()
    }
}

struct BridgeView: UIViewRepresentable {
    
    func makeUIView(context: Context) -> some UIView {
        let bridgeView = UILabel(frame: .zero)
        bridgeView.backgroundColor = UIColor.red
        return bridgeView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        
    }
}
bridge1.png

接下来我们给BridgeView设置一个初始化方法并传入一个字符串,通过点击按钮改变文本的显示:

struct ContentView: View {
    @State var text = "Hello, world!"
    
    var body: some View {
        VStack{
            Button("改变文字") {
                text = ["Hello","Hello, world!","你好","你好,世界!"].randomElement()!
            }
            BridgeView(text)
                .frame(height: 100)
                .padding()
        }
        
    }
}

struct BridgeView: UIViewRepresentable {
    let string:String
    init(_ string:String = "") {
        self.string = string
    }
    
    func makeUIView(context: Context) -> some UIView {
        let bridgeView = UILabel(frame: .zero)
        bridgeView.backgroundColor = UIColor.red
        return bridgeView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        let bridgeView:UILabel? = uiView as? UILabel
        bridgeView!.text = self.string
    }
}

运行项目你会发现,makeUIView只执行一次,string发生改变时initupdateUIView都会重新执行一次。用于更新界面的变化。这里有个问题,为什么init方法会重新执行?因为BridgeViewstruct类型。

创建自定义实例,绑定变量到SwiftUI属性:

struct BridgeView: UIViewRepresentable {
    @Binding var string:String
    init(_ string:Binding<String>) {
        _string = string
    }
    init(_ string:String) {
        _string = .constant(string)
    }
    
    func makeUIView(context: Context) -> some UIView {
        let bridgeView = UILabel(frame: .zero)
        bridgeView.backgroundColor = UIColor.red
        return bridgeView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        let bridgeView:UILabel? = uiView as? UILabel
        bridgeView!.text = string
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator($string)
    }
    
    class Coordinator {
        let string:Binding<String>
        init(_ string: Binding<String>) {
            self.string = string
        }
    }
}

然后使用BridgeView($text),点击按钮发现init(_ string:Binding<String>)只会执行一次了。

有了Binding我们可以双向修改数据了,把UILabel改为UIButton并添加点击事件,改变string值:

struct BridgeView: UIViewRepresentable {
    @Binding var string:String
    init(_ string:Binding<String>) {
        _string = string
    }
    init(_ string:String) {
        _string = .constant(string)
    }
    
    func makeUIView(context: Context) -> some UIView {
        let bridgeView = UIButton(frame: .zero)
        bridgeView.backgroundColor = UIColor.red
        bridgeView.addTarget(context.coordinator, action: #selector(Coordinator.onChangedText), for: .touchUpInside)
        return bridgeView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        let bridgeView:UIButton? = uiView as? UIButton
        bridgeView!.setTitle(string, for: UIControl.State.normal)
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator($string)
    }
    
    class Coordinator {
        let string:Binding<String>
        init(_ string: Binding<String>) {
            self.string = string
        }
        @objc
        func onChangedText() {
            string.wrappedValue = "onChangedText"
        }
        
    }
}
binging.gif

List桥接

有了上面的基础,我们可以在swiftUI中使用List组件,要使用下拉刷新功能:

  • 从视图层次中找到List对应到UIKit中的组件UITableView
  • UITableView做一些操作,添加下拉刷新组件
  • Binging属性回传给swiftUI并作出响应
struct ListView: View {
    
    var array = ["a","b","c","d","e","f","g"]
    
    var body: some View {
        NavigationView {
          List(array, id: \.self) { text in
            Text(text)
          }
          .navigationBarTitle("刷新demo")
        }
        .background(PullRefresh())
    }
}

struct PullRefresh: UIViewRepresentable {
    
    func makeUIView(context: Context) -> some UIView {
        let view = UIView(frame: .zero)
        return view
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        DispatchQueue.main.asyncAfter(deadline: .now()) {
            guard let viewHost = uiView.superview?.superview else {
                return
            }
            guard let tableView = self.tableView(root: viewHost) else {
                return
            }
            print("find:\(tableView)")
        }
    }
    
    private func tableView(root: UIView) -> UITableView? {
        for subview in root.subviews {
            if subview.isKind(of: UITableView.self) {
                return subview as? UITableView
            } else if let tableView = tableView(root: subview) {
                return tableView
            }
        }
        return nil
    }
    
}

注意:updateUIView方法中,获取uiView父视图和祖父视图,都发现获取不到。而在DispatchQueue.main.asyncAfter方法返回主线程才能获取到。
我们从祖父视图结构中使用tableView(root: UIView)方法递归查找UITableView,然后将其返回。

让我们添加下拉组件,更新updateUIView方法中的代码:

func updateUIView(_ uiView: UIViewType, context: Context) {
   DispatchQueue.main.asyncAfter(deadline: .now()) {
       guard let viewHost = uiView.superview?.superview else {
           return
       }
       guard let tableView = self.tableView(root: viewHost) else {
           return
       }
       
       let refreshControl = UIRefreshControl()
       tableView.refreshControl = refreshControl
   }
}
refreshing.gif

我们看到刷新组件是显示处理了,但是有个问题是它一直会在那里显示着,它将在什么时机隐藏呢?比如我下拉放手,开始做网络请求,请求完成后隐藏刷新组件。

属性绑定交互

PullRefresh组件声明一个可绑定的属性@Binding var isRefreshing: Bool,声明一个class引用类型Coordinator用于绑定数据的同步。

struct ListView: View {
    @State var isRefreshing: Bool = false
    
    var array = ["a","b","c","d","e","f","g"]
    
    var body: some View {
        NavigationView {
          List(array, id: \.self) { text in
            Text(text)
          }
          .navigationBarTitle("刷新demo")
        }
        .background(PullRefresh(isRefreshing: $isRefreshing, action: {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                self.isRefreshing = false
            }
        }))
    }
}

struct PullRefresh: UIViewRepresentable {
    @Binding var isRefreshing: Bool
    let action: (() -> Void)?
    
    init(isRefreshing: Binding<Bool>,action: (() -> Void)? = nil) {
        _isRefreshing = isRefreshing
        self.action = action
    }
    
    func makeUIView(context: Context) -> some UIView {
        let view = UIView(frame: .zero)
        return view
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        DispatchQueue.main.asyncAfter(deadline: .now()) {
            guard let viewHost = uiView.superview?.superview else {
                return
            }
            guard let tableView = self.tableView(root: viewHost) else {
                return
            }
            if let refreshControl = tableView.refreshControl {
                if self.isRefreshing {
                    refreshControl.beginRefreshing()
                } else {
                    refreshControl.endRefreshing()
                }
                
            }else {
                let refreshControl = UIRefreshControl()
                refreshControl.addTarget(context.coordinator, action: #selector(Coordinator.onValueChanged), for: .valueChanged)
                tableView.refreshControl = refreshControl
            }
            
            
        }
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator($isRefreshing, action: action)
    }
    
    class Coordinator {
        let isRefreshing: Binding<Bool>
        let action: (() -> Void)?
        
        init(_ isRefreshing: Binding<Bool>,action: (() -> Void)?) {
            self.isRefreshing = isRefreshing
            self.action = action
        }

        @objc
        func onValueChanged() {
            isRefreshing.wrappedValue = true
            if let actionMethod = action {
                actionMethod()
            }
        }
    }
    
    private func tableView(root: UIView) -> UITableView? {
        for subview in root.subviews {
            if subview.isKind(of: UITableView.self) {
                return subview as? UITableView
            } else if let tableView = tableView(root: subview) {
                return tableView
            }
        }
        return nil
    }
    
}
refreshed.gif

另一种使用方式是这样的:

class ModelObject: ObservableObject {
    @Published var isRefreshing: Bool = false {
        didSet {
            if isRefreshing {
                //刷新发起网络请求
                requestData()
            }
        }
    }
    
    func requestData() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            self.isRefreshing = false
        }
    }
}

struct ListView: View {
    @ObservedObject var modelObject = ModelObject()
    
    var array = ["a","b","c","d","e","f","g"]
    
    var body: some View {
        NavigationView {
          List(array, id: \.self) { text in
            Text(text)
          }
          .navigationBarTitle("刷新demo")
        }
        .background(PullRefresh(isRefreshing: $modelObject.isRefreshing))
    }
}

到此看似我们的功能已经完成了。但是我们发现我们只要下拉后,事件回调就会被触发,即使我们不送手。我们将在下一篇文章解决这个问题,并做进一步优化操作!

总结

使用UIViewRepresentable可以解决一些SwiftUI目前解决不了或解决起来比较麻烦的问题。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容