SwiftUIで作っているプロジェクトの中に作ったUIView(UIViewRepresentable経由)で親ViewのViewModelを更新したい
履歴
2022年11月9日 初版
2022年11月9日 履歴にかかれていた初版の日付修正と、Githubへのリンク追加
開発環境
XCode Version 14.0 (14A309)
Swift version 5.7
準備
確認用CustomUIView
CustomUIViewという名前で以下のXibファイルを作る
制約は適当
CustomClassは次のようにする。
import UIKit
class CustomUIView: UIView {
@IBOutlet private weak var messageLabel: UILabel!
var label: String {
get {
messageLabel?.text ?? ""
}
set {
messageLabel?.text = newValue
}
}
override init(frame: CGRect) {
super.init(frame: frame)
loadNib()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
loadNib()
}
func loadNib() {
let nibName = String(describing: type(of: self))
let bundle = Bundle(for: type(of: self))
if let view = bundle.loadNibNamed(nibName, owner: self, options: nil)?.first as? UIView {
view.frame = self.bounds
self.addSubview(view)
}
}
@IBAction private func tappedChangeMessage(_ sender: UIButton) {
let formatter = DateFormatter()
formatter.timeStyle = .medium
formatter.dateStyle = .medium
formatter.locale = Locale(identifier: "ja_JP")
let now = Date()
label = formatter.string(from: now)
}
}
確認用UIViewRepresentable
struct CustomView: UIViewRepresentable {
typealias UIViewType = CustomUIView
func makeUIView(context: Context) -> CustomUIView {
let customView = CustomUIView()
return customView
}
func updateUIView(_ uiView: CustomUIView, context: Context) {
// 動作無し
}
}
確認用ContentView
struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
CustomView()
.frame(height: 100)
}
}
}
ここまでの動作を確認する
画面上に「Label」と表示されて、「時間」を押すと時間が表示される
SwiftUI部分とViewModelを作る
// ViewModel
class ContentViewModel: ObservableObject {
@Published var displayingLabel: String = "hogeらっちょ"
}
ContentViewを全体的に修正する
struct ContentView: View {
@StateObject var viewModel = ContentViewModel()
var body: some View {
VStack(alignment: .leading) {
HStack {
Spacer()
Text("SwiftUI(のViewModelを変える)↓")
Spacer()
}
HStack {
Text(viewModel.displayingLabel)
.padding(.horizontal, 10)
Spacer()
Button {
viewModel.displayingLabel = "きえた"
} label: {
Text("くりあ")
.frame(width: 75, height: 60)
.background(.blue)
.foregroundColor(Color.white)
.padding(.trailing, 20)
}
}
HStack {
Spacer()
Text("UIViewからViewModelを変える↓")
Spacer()
}
CustomView()
.frame(height: 100)
}
.padding()
}
}
「くりあ」を押すと「hogeらっちょ」は「きえた」に変わる
UIViewの「時間」ボタンを押したら「hogeらっちょ」の値を変える
UIViewの値を変更したらViewModelの値を変更する
CustomViewにViewModelを渡すようにする
struct CustomView: UIViewRepresentable {
typealias UIViewType = CustomUIView
+ @ObservedObject var viewModel: ContentViewModel
func makeUIView(context: Context) -> CustomUIView {
let customView = CustomUIView()
+ customView.viewModel = viewModel
return customView
}
func updateUIView(_ uiView: CustomUIView, context: Context) {
}
}
ContentViewもViewModelを渡すようにする
struct ContentView: View {
@StateObject var viewModel = ContentViewModel()
var body: some View {
VStack(alignment: .leading) {
HStack {
Spacer()
Text("SwiftUI(のViewModelを変える)↓")
Spacer()
}
HStack {
Text(viewModel.displayingLabel)
.padding(.horizontal, 10)
Spacer()
Button {
viewModel.displayingLabel = "きえた"
} label: {
Text("くりあ")
.frame(width: 75, height: 60)
.background(.blue)
.foregroundColor(Color.white)
.padding(.trailing, 20)
}
}
HStack {
Spacer()
Text("UIViewからViewModelを変える↓")
Spacer()
}
- CustomView()
+ CustomView(viewModel: viewModel)
.frame(height: 100)
}
.padding()
}
}
CustomUIViewもViewModelを受け取れるようにする
import UIKit
class CustomUIView: UIView {
@IBOutlet private weak var messageLabel: UILabel!
+ var viewModel: ContentViewModel?
var label: String {
get {
messageLabel?.text ?? ""
}
set {
messageLabel?.text = newValue
}
}
override init(frame: CGRect) {
super.init(frame: frame)
loadNib()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
loadNib()
}
func loadNib() {
let nibName = String(describing: type(of: self))
let bundle = Bundle(for: type(of: self))
if let view = bundle.loadNibNamed(nibName, owner: self, options: nil)?.first as? UIView {
view.frame = self.bounds
self.addSubview(view)
}
}
@IBAction private func tappedChangeMessage(_ sender: UIButton) {
let formatter = DateFormatter()
formatter.timeStyle = .medium
formatter.dateStyle = .medium
formatter.locale = Locale(identifier: "ja_JP")
let now = Date()
label = formatter.string(from: now)
+ // viewModelの値を変更する
+ guard let viewModel else { return }
+ viewModel.displayingLabel = formatter.string(from: now)
}
}
確認すると、時間を押すとHogeらっちょは変わることがわかる
ただ、「くりあ」を押してもUIView側の値は変わらない
SwiftUI側からUIKit側の値を更新する
方法
- updateUIViewメソッドを使う
- Combineを使う
updateUIViewメソッドを使う方法
これはかなり簡単
updateUIViewは画面描画のタイミングで呼ばれる
SwiftUI側で再描画が起きた時に自動で呼ばれるので修正が1行で足りる
struct CustomView: UIViewRepresentable {
typealias UIViewType = CustomUIView
@ObservedObject var viewModel: ContentViewModel
func makeUIView(context: Context) -> CustomUIView {
let customView = CustomUIView()
customView.viewModel = viewModel
return customView
}
func updateUIView(_ uiView: CustomUIView, context: Context) {
+ uiView.label = viewModel.displayingLabel
}
}
Combineを使う
Combine大好き! って人でもあまり採用しなさそうな対応
import UIKit
+import Combine
class CustomUIView: UIView {
@IBOutlet private weak var messageLabel: UILabel!
+ private var cancellables = Set<AnyCancellable>()
- var viewModel: ContentViewModel?
+ var viewModel: ContentViewModel? {
+ didSet {
+ if oldValue !== viewModel {
+ guard let viewModel else { return }
+ viewModel.$displayingLabel
+ .map { Optional($0) }
+ .receive(on: DispatchQueue.main)
+ .assign(to: \UILabel.text, on: messageLabel)
+ .store(in: &cancellables)
+ }
+ }
+ }
- var label: String {
- get {
- messageLabel?.text ?? ""
- }
- set {
- messageLabel?.text = newValue
- }
- }
override init(frame: CGRect) {
super.init(frame: frame)
loadNib()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
loadNib()
}
func loadNib() {
let nibName = String(describing: type(of: self))
let bundle = Bundle(for: type(of: self))
if let view = bundle.loadNibNamed(nibName, owner: self, options: nil)?.first as? UIView {
view.frame = self.bounds
self.addSubview(view)
}
}
@IBAction private func tappedChangeMessage(_ sender: UIButton) {
let formatter = DateFormatter()
formatter.timeStyle = .medium
formatter.dateStyle = .medium
formatter.locale = Locale(identifier: "ja_JP")
let now = Date()
- label = formatter.string(from: now)
guard let viewModel else { return }
viewModel.displayingLabel = formatter.string(from: now)
}
}
label = formatter.string(from: now)
消してしまうから、viewModelがnilの時困るけれども、
その場合は設計ミスということで。
参考用
今回使ったソースが置かれているGithubへのリンク