How to access property in SwiftUI wrapper that conforms to UIViewRepresentable? - swiftui

I'd like to control an instance of a AVPlayer that is instantiated and assigned to a property in the class VideoPlayer which conforms to UIView. To make sure it's clear what my intent is, I've put together a known pattern when dealing with SwiftUI and UIViewRepresentable wrappers, as follows:
class VideoPlayer: UIView {
private let playerLayer = AVPlayerLayer()
private var previewTimer: Timer?
var previewLength: Double
var player: AVPlayer?
init(frame: CGRect, url: URL, previewLength:Double) {
self.previewLength = previewLength
super.init(frame: frame)
self.player = AVPlayer(url: url)
self.player?.volume = 0
self.player?.play()
NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: self.player?.currentItem, queue: .main) { [weak self] _ in
self?.player?.seek(to: CMTime.zero)
self?.player?.play()
}
playerLayer.player = player
playerLayer.videoGravity = .resizeAspectFill
playerLayer.backgroundColor = UIColor.black.cgColor
previewTimer = Timer.scheduledTimer(withTimeInterval: previewLength, repeats: true, block: { (timer) in
self.player?.seek(to: CMTime(seconds: 0, preferredTimescale: CMTimeScale(1)))
})
layer.addSublayer(playerLayer)
}
required init?(coder: NSCoder) {
self.previewLength = 15
super.init(coder: coder)
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = bounds
}
}
The wrapper MyWrapper exposes the UIView to SwiftUI, and the wrapper can have public properties for example myState:
struct MyWrapper: UIViewRepresentable {
var videoURL: URL
var previewLength: Double
#Binding var mySate: Bool
func makeUIView(context: Context) -> UIView {
let videoPlayer = VideoPlayer(frame: .zero, url: videoURL, previewLength: previewLength)
return videoPlayer
}
func updateUIView(_ uiView: UIView, context: Context) {
if myState {
// do something
} else {
// do something else
}
}
}
If required to control a property of the instance VideoPlayer, we might be tempted to access properties directly from the UIView object in the method updateUIView. So, let's say that hypothetically we want to access the player property:
func updateUIView(_ uiView: UIView, context: Context) {
if myState {
uiView.player.play()
}
}
This fails with the error Value of type 'UIView' has no member 'player'.
To overcome this created a proxy in the MyWrapper to reference the AVPlayer instance, as follows:
struct MyWrapper: UIViewRepresentable {
var videoURL: URL
var previewLength: Double
#State var player: AVPlayer?
#Binding var play: Bool
func makeUIView(context: Context) -> UIView {
let videoPlayer = VideoPlayer(frame: .zero, url: videoURL, previewLength: previewLength)
DispatchQueue.main.async {
self.player = videoPlayer.player
}
return videoPlayer
}
func updateUIView(_ uiView: UIView, context: Context) {
if play {
self.player?.play()
} else {
self.player?.pause()
self.player?.rate = 0
}
}
}
Obs: We use the DispatchQueue.main to bypass a warning Modifying state during view update, this will cause undefined behaviour that'd be thrown in makeUIView.
This approach works but I haven't found any documentation in Apple confirming that this is the right approach when dealing with SwiftUI and UIViewRepresentable wrappers, as SwiftUI is a black box.
So, for this reason I'd like to know how to access a property in SwiftUI wrapper that conforms to UIViewRepresentable?

UIViewRepresentable infers type, in this case, from declared return/argument type, so just highlight explicitly what type is represented
struct MyWrapper: UIViewRepresentable {
var videoURL: URL
var previewLength: Double
#Binding var mySate: Bool
func makeUIView(context: Context) -> VideoPlayer {
let videoPlayer = VideoPlayer(frame: .zero, url: videoURL, previewLength: previewLength)
return videoPlayer
}
func updateUIView(_ uiView: VideoPlayer, context: Context) {
if myState {
uiView.player.play() // now it knows that uiView is-a VideoPlayer
} else {
// do something else
}
}
}

Related

SwiftUI and UIKit Interoperability with displaying multiple views

Overview: I'm using SwiftUI, but wanted to use UIKit-MapKit. I used UIViewRepresentable to be able to wrap the UIKit feature.
Problem: I'm learning about swiftui-uikit-interoperability and I'm getting stuck on being able to display multiple SwiftUI views.
Code Snippet:
ContentView
struct ContentView: View {
#ObservedObject var viewModel: MapView.PinViewModel
init() {
self.viewModel = MapView.PinViewModel()
}
var body: some View {
NavigationView {
MapView()
.sheet(isPresented: $viewModel.showPinForm) {
PinForm()
}
.navigationTitle("SwiftUI UIKit Interop").scaledToFill()
}
}
}
MapView
struct MapView: UIViewRepresentable {
class PinViewModel: ObservableObject {
#Published var showPinForm: Bool
init() {
self.showPinForm = false
}
func updateShowPinVar() {
self.showPinForm = true
}
}
func showPinForm() {
pinViewModel.updateShowPinVar()
}
func makeCoordinator() -> MapViewCoordinator {
let coordinator = MapViewCoordinator()
coordinator.delegate = self
return coordinator
}
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
let coordinate = CLLocationCoordinate2D(latitude: 40.7209, longitude: -74.0007)
let span = MKCoordinateSpan(latitudeDelta: 0.03, longitudeDelta: 0.03)
let mapRegion = MKCoordinateRegion(center: coordinate, span: span)
mapView.setRegion(mapRegion, animated: true)
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
}
}
In this I have a #Published var showPinForm that gets toggled in MapView. ContentView is supposed to watch this variable and when it is true it will cause the sheet to pull up. However, I believe when I enter MapView() from ContentView() then I no longer recognize ContentView.
Using the UIViewRepresentable, what is the best way to display another swiftui view? Does not have to use .sheet (Although, it would be nice)
I have tried to simplify the code to show the main problem, so I left out a lot of additional info and took out basic patterns that I used (MVVM)
Please let me know if you need any clarifications
try to follow this pattern, you can toggle the flag both inside and outside your MapView
struct MapView: UIViewRepresentable {
#Binding var switcher: Bool // -> use binding
func makeUIView(context: Context) -> MKMapView { MKMapView() }
func updateUIView(_ uiView: MKMapView, context: Context) { }
}
struct MainView: View {
#ObservedObject var viewModel = MainViewModel()
var body: some View {
MapView(switcher: $viewModel.flag)
.sheet(isPresented: $viewModel.flag) {
Text("Pin pin")
}
}
}
class MainViewModel: ObservableObject {
#Published var flag: Bool = false
}

Unexpected acting of MapKit in SwiftUI

I'm stuck with the really strange problem. I'm implementing map into my SwiftUI app. It should act like a normal map (drag, scroll and so on). When changing position (that is binding point) the app gets an address via geocoder.
Also user can click "Change" button and enter address manually (with autocompletion). After selecting the address, the map should move to the reverse geocoded point.
Built-in SwiftUI Map() is a good thing, but... it's unreal to make it show buildings. And in the app it's something that matters. So, going with UIViewRepresentable gives me another strange problem.
If I set the center coordinate in UpdateUIView, the map stops any interactivity. Otherwise changing the address manually doesn't work.
What could be wrong with this?
struct MapView: UIViewRepresentable {
#Binding var point: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.showsBuildings = true
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// uiView.setCenter(point, animated: true)
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
self.parent.point = mapView.centerCoordinate
}
}
}
I tried wrapping everything into DispatchQueue.main.async {} - not working (and honestly I don't think it could)
I also tried this solution, but it worked neither: https://www.reddit.com/r/SwiftUI/comments/kti9r9/uiviewrepresentable_how_to_update_bindings/
I also had the same problem. I solved this using #state. So every time the mapView changes, the corresponding function of the coordinator is definitely called. Hope it helps.
struct YourView: View {
#State mapView: MKMapView = .init()
#State var point: CLLocationCoordinate2D = [...]
var body: some View {
MapView(mapView: $mapView, point: $point)
...
}
struct MapView: UIViewRepresentable {
#Binding var mapView: MKMapView
#Binding var point: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
self.mapView = MKMapView()
self.mapView.showsBuildings = true
self.mapView.delegate = context.coordinator
return self.mapView
}
...
func updateUIView(_ uiView: MKMapView, context: Context) {
// uiView.setCenter(point, animated: true)
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
self.parent.point = self.mapView.centerCoordinate
}
}
}

Swiftui - Access UIKit methods/properties from UIViewRepresentable

I have created a SwiftUI TextView based on a UITextView using UIViewRepresentable (s. code below). Displaying text in Swiftui works OK.
But now I need to access internal functions of UITextView from my model. How do I call e.g. UITextView.scrollRangeToVisible(_:) or access properties like UITextView.isEditable ?
My model needs to do these modifications based on internal model states.
Any ideas ? Thanks
(p.s. I am aware of TextEditor in SwiftUI, but I need support for iOS 13!)
struct TextView: UIViewRepresentable {
#ObservedObject var config: ConfigModel = .shared
#Binding var text: String
#State var isEditable: Bool
var borderColor: UIColor
var borderWidth: CGFloat
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let myTextView = UITextView()
myTextView.delegate = context.coordinator
myTextView.isScrollEnabled = true
myTextView.isEditable = isEditable
myTextView.isUserInteractionEnabled = true
myTextView.layer.borderColor = borderColor.cgColor
myTextView.layer.borderWidth = borderWidth
myTextView.layer.cornerRadius = 8
return myTextView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.font = uiView.font?.withSize(CGFloat(config.textsize))
uiView.text = text
}
class Coordinator : NSObject, UITextViewDelegate {
var parent: TextView
init(_ uiTextView: TextView) {
self.parent = uiTextView
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
return true
}
func textViewDidChange(_ textView: UITextView) {
self.parent.text = textView.text
}
}
}
You can use something like configurator callback pattern, like
struct TextView: UIViewRepresentable {
#ObservedObject var config: ConfigModel = .shared
#Binding var text: String
#State var isEditable: Bool
var borderColor: UIColor
var borderWidth: CGFloat
var configurator: ((UITextView) -> ())? // << here !!
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let myTextView = UITextView()
myTextView.delegate = context.coordinator
myTextView.isScrollEnabled = true
myTextView.isEditable = isEditable
myTextView.isUserInteractionEnabled = true
myTextView.layer.borderColor = borderColor.cgColor
myTextView.layer.borderWidth = borderWidth
myTextView.layer.cornerRadius = 8
return myTextView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.font = uiView.font?.withSize(CGFloat(config.textsize))
uiView.text = text
// alternat is to call this function in makeUIView, which is called once,
// and the store externally to send methods directly.
configurator?(myTextView) // << here !!
}
// ... other code
}
and use it in your SwiftUI view like
TextView(...) { uiText in
uiText.isEditing = some
}
Note: depending on your scenarios it might be additional conditions need to avoid update cycling, not sure.

Customize Mapbox style with layers in SwiftUI

I have an issue to customize a Mapbox view's style, like for instance adding some information on the map if a switch is switched on. Not sure if it's important, but the layers I need to add are MGLSymbolStyleLayer and MGLLineStyleLayer.
Let's start with code for the main view containing a switch representing a state used to customize the map's style, and an UIViewRepresentable for the Mapbox view.
struct Test_MapBox: View {
#State private var styleURL: URL = MGLStyle.outdoorsStyleURL
#State private var switchButton: Bool = false
var body: some View {
ZStack(alignment: .bottom) {
MapView(switchButton: switchButton)
.styleURL(styleURL)
.edgesIgnoringSafeArea(.all)
Toggle(isOn: $switchButton, label: {
Text("Switch")
})
}
}
}
struct MapView: UIViewRepresentable {
var switchButton: Bool
var mapView = MGLMapView(frame: .zero)
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MGLMapView {
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ uiView: MGLMapView, context: UIViewRepresentableContext<MapView>) {
print("Style: \(uiView.style)")
print("Update view, switch: \(switchButton)")
}
func makeCoordinator() -> MapView.Coordinator {
Coordinator(self)
}
func styleURL(_ styleURL: URL) -> MapView {
mapView.styleURL = styleURL
return self
}
final class Coordinator: NSObject, MGLMapViewDelegate {
var parent: MapView
init(_ control: MapView) {
self.parent = control
}
func mapViewDidFinishLoadingMap(_ mapView: MGLMapView) {
print("Map loaded, switch: \(parent.switchButton)")
}
func mapView(_ mapView: MGLMapView, didFinishLoading style: MGLStyle) {
print("Style loaded, switch: \(parent.switchButton)")
}
}
}
The issue is the following: inside the delegate functions, switchButton is never up to date, always false (and I don't understand why)... And in updateUIView(), switchButton is OK, but the style is not yet loaded, so usually you get a nil when accessing it ...
Have you got a solution ?

SwiftUI and AVPlayer

I'm trying to get an AVPlayer layer in my SwiftUI interface.
Google doesn't have many answers on the subject, in fact, there was a tutorial that looked promising see: https://medium.com/#chris.mash/avplayer-swiftui-b87af6d0553. But it was full of bugs. So, I tried going about this my own way.
The plan: create a UIView subclass and add an AVPlayerLayer to it, then, wrap the UIView for SwiftUI.
The results: nothing.
Here's what I've got so far:
struct PlayerView : UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
return PlayerViewSwift()
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
And then the PlayerViewSwift class:
class PlayerViewSwift : UIView {
private let playerLayer = AVPlayerLayer()
init() {
super.init(frame: .infinite)
}
override init(frame: CGRect) {
super.init(frame: frame)
}
// This attribute hides `init(coder:)` from subclasses
#available(*, unavailable)
required init?(coder aDecoder: NSCoder) {
fatalError("NSCoding not supported")
}
override func layoutSubviews() {
super.layoutSubviews()
// playerLayer.player =
print("hmmm")
let player = AVPlayer(url: URL(string: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u80")!)
player.play()
playerLayer.player = player
layer.addSublayer(playerLayer)
}
}
Remove the trailing "0" from the URL path extension, so that it is "m3u8" instead of "m3u80".
Also, set playerLayer's frame equal to PlayerViewSwift's bounds in the end of layoutSubviews():
self.playerLayer.frame = self.bounds
Swift 5.1, iOS 13.
I read Chris Mash tutorials too [there for 4 of them] and put this together. I certainly don't recall them being full of bugs, maybe a few typos.
It plays either an embedded video or a streaming one in SwiftUI navigation controller managed page.
import Foundation
import SwiftUI
import AVFoundation
import Combine
let timePublisher = PassthroughSubject<TimeInterval, Never>()
let videoFinished = PassthroughSubject<Void, Never>()
let nextFrame = PassthroughSubject<Void, Never>()
struct PlayerTimeView: View {
#State private var currentTime: TimeInterval = 0
var body: some View {
Text("\(currentTime)")
.onReceive(timePublisher) { time in
self.currentTime = time
}.statusBar(hidden: true)
.navigationBarHidden(true)
.navigationBarBackButtonHidden(true)
}
}
class PlayerUIView: UIView {
private var timeObservation: Any?
private let playerLayer = AVPlayerLayer()
override init(frame: CGRect) {
super.init(frame: .zero)
let url = Bundle.main.url(forResource: "AppDemo", withExtension: "mov")
// let url = URL(string: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8")!
// let url = URL(string: "https://www.youtube.com/watch?v=XK8METRgK_U")!
let player = AVPlayer(url: url!)
player.play()
timeObservation = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: nil) { [weak self] time in
guard let self = self else { return }
// Publish the new player time
print("time.seconds ", time.seconds)
timePublisher.send(time.seconds)
NotificationCenter.default.addObserver(self, selector: #selector(self.finishVideo), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil)
}
playerLayer.player = player
layer.addSublayer(playerLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = CGRect(x: 0, y: -115, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
}
#objc func finishVideo() {
print("Video Finished")
NotificationCenter.default.removeObserver(NSNotification.Name.AVPlayerItemDidPlayToEndTime)
videoFinished.send()
nextFrame.send()
}
}
struct PlayerView: UIViewRepresentable {
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PlayerView>) {
}
func makeUIView(context: Context) -> UIView {
PlayerUIView(frame: .zero)
}
}
struct PlayerPage: View {
#EnvironmentObject var env: MyAppEnvironmentData
#Environment(\.presentationMode) var presentation
var body: some View {
VStack {
PlayerView().onReceive(videoFinished) { (_) in
self.presentation.wrappedValue.dismiss()
}
HStack {
Spacer()
PlayerTimeView()
Spacer()
}
}
}
}