In my SwiftUI App, I have button "Pick A Document", which, when clicked opens a sheet displaying all the available document for user to select. I have a #State #Binding variables setup both in my ContentView and DocumentPicker.
I have onDismiss function in the sheet modifier which gets fired after user has picked a document. Inside onDismiss i am printing the size of the document that user has selected and later i intend to do some operation with the document.
Issue is: When user selects a documents the #State variable does not get updated instantly but it still holds the previous object.
To explain it better, when user clicks the "Pick a Document" for first time at that time State variable has a value of #State var doc: Data = Data() but when user selects a document and sheet is dismissed #Binding variable inside DocumentPicker is updated and SwiftUI must ensure that value is reflected in the Parent View #State Variable. Therefor when onDismiss is called it must print the size of the file that user had selected.
But in reality, it still prints 0 Bytes which is the value given to the #State variable during initialisation.
Now when user again clicks the "Pick a Document" Button and selects another file or the same file that S/he selected last time, now when sheet is dismissed onDismiss method prints the size of the file that user had chosen last time i.e. file selected just before the currently selected file.
Is this expected behaviour if so then how can i get the file returned to my view which user had currently selected?
Here is the complete code:
import SwiftUI
struct ContentView: View {
#State var showSheet = false
#State var doc: Data = Data()
var body: some View {
Button(action: {
showSheet.toggle()
}, label: {
Text("Pick a Document")
}).sheet(isPresented: $showSheet, onDismiss: upload, content: {
DocumentPicker(s3Document: $doc)
})
}
func upload() {
debugPrint("File Size: ", doc)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct DocumentPicker: UIViewControllerRepresentable {
#Binding var s3Document: Data
class Coordinator: NSObject, UINavigationControllerDelegate, UIDocumentPickerDelegate {
var parent: DocumentPicker
init(_ parent: DocumentPicker) {
self.parent = parent
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
let fm = FileManager.init()
if let file = fm.contents(atPath: urls.first!.path) {
parent.s3Document = file
}
}
}
func makeUIViewController(context: UIViewControllerRepresentableContext<DocumentPicker>) -> UIDocumentPickerViewController {
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.data])
documentPicker.delegate = context.coordinator
documentPicker.allowsMultipleSelection = false
return documentPicker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: UIViewControllerRepresentableContext<DocumentPicker>) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}
I have fixed by useing startAccessingSecurityScopedResource()(for more https://developer.apple.com/documentation/foundation/url/1779698-startaccessingsecurityscopedreso).
Change your didPickDocumentsAt method with this. Also, use Data(contentsOf: url) for get file data from the url.
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first, url.startAccessingSecurityScopedResource() else { return }
defer {
DispatchQueue.main.async {
url.stopAccessingSecurityScopedResource()
}
}
do {
parent.s3Document = try Data(contentsOf: url)
} catch {
print("no data")
}
}
One more thing needs to add inside the upload() function. Add all code inside the DispatchQueue.main.async. Because of this function call before the value assign. You can also use closure in an alternative way.
func upload() {
DispatchQueue.main.async {
debugPrint("File Size: ", doc)
}
}
Related
I'm doing a comparison of Core Data and Realm in a SwiftUI app, and Core Data does something that I'm hoping to figure out how to do in Realm.
Core Data lets you mutate objects whenever you want, and when they are ObservableObject in SwiftUI, your UI instantly updates. You then save the context whenever you want to persist the changes.
In Realm, the objects in the UI are live, but you can't change them unless you are in a write transaction. I'm trying to get my UI to reflect live/instant changes from the user when the actual write is only performed occasionally. Below is a sample app.
Here's my Realm model:
import RealmSwift
class Item: Object, ObjectKeyIdentifiable{
#objc dynamic var recordName = ""
#objc dynamic var text = ""
override class func primaryKey() -> String? {
return "recordName"
}
}
Here is my view model that also includes my save() function that only saves every 3 seconds. In my actual app, this is because it's an expensive operation and doing it as the user types brings the app to a crawl.
class ViewModel: ObservableObject{
static let shared = ViewModel()
#Published var items: Results<Item>!
#Published var selectedItem: Item?
var token: NotificationToken? = nil
init(){
//Add dummy data
let realm = try! Realm()
realm.beginWrite()
let item1 = Item()
item1.recordName = "one"
item1.text = "One"
realm.add(item1, update: .all)
let item2 = Item()
item2.recordName = "two"
item2.text = "Two"
realm.add(item2, update: .all)
try! realm.commitWrite()
self.fetch()
//Notifications
token = realm.objects(Item.self).observe{ [weak self] _ in
self?.fetch()
}
}
//Get Data
func fetch(){
let realm = try! Realm()
self.items = realm.objects(Item.self)
}
//Save Data
var saveTimer: Timer?
func save(item: Item, text: String){
//Save occasionally
saveTimer?.invalidate()
saveTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false){ _ in
let realm = try! Realm()
try? realm.write({
item.text = text
})
}
}
}
Last of all, here is the UI. It's pretty basic and reflects the general structure of my app where I'm trying to pull this off.
struct ContentView: View {
#StateObject var model = ViewModel.shared
var body: some View {
VStack{
ForEach(model.items){ item in
HStack{
Button(item.text){
model.selectedItem = item
}
Divider()
ItemDetail(item: item)
}
}
}
}
}
...and the ItemDetail view:
struct ItemDetail: View{
#ObservedObject var item: Item
#StateObject var model = ViewModel.shared
init(item: Item){
self.item = item
}
var body: some View{
//Binding
let text = Binding<String>(
get: { item.text },
set: { model.save(item: item, text: $0) }
)
TextField("Text...", text: text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
When I type in the TextField, how do I get the Button text to reflect what I have typed in real time considering that my realm.write only happens every 3 seconds? My Button updates after a write, but I want the UI to respond live--independent of the write.
Based on the suggested documentation from Jay, I got the following to work which is quite a bit simpler:
My main view adds the #ObservedResults property wrapper like this:
struct ContentView: View {
#StateObject var model = ViewModel.shared
#ObservedResults(Item.self) var items
var body: some View {
VStack{
ForEach(items){ item in
HStack{
Button(item.text){
model.selectedItem = item
}
Divider()
ItemDetail(item: item)
}
}
}
}
}
...and then the ItemDetail view simply uses an #ObservedRealmObject property wrapper that binds to the value in Realm and manages the writes automatically:
struct ItemDetail: View{
#ObservedRealmObject var item: Item
var body: some View{
TextField("Text...", text: $item.text)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
This is essentially how Core Data does it (in terms view code) except Realm saves to the store automatically. Thank you, Jay!
I want to allow the user to filter data in a long list to more easily find matching titles.
I have placed a TextView inside my navigation bar:
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: TextField("search", text: $modelData.searchString)
I have an observable object which responds to changes in the search string:
class DataModel: ObservableObject {
#Published var modelData: [PDFSummary]
#Published var searchString = "" {
didSet {
if searchString == "" {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name })
} else {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name }).filter({ $0.name.lowercased().contains(searchString.lowercased()) })
}
}
}
Everything works fine, except I have to tap on the field after entering each letter. For some reason the focus is taken away from the field after each letter is entered (unless I tap on a suggested autocorrect - the whole string is correctly added to the string at once)
The problem is in rebuilt NavigationView completely that result in dropped text field focus.
Here is working approach. Tested with Xcode 11.4 / iOS 13.4
The idea is to avoid rebuild NavigationView based on knowledge that SwiftUI engine updates only modified views, so using decomposition we make modifications local and transfer desired values only between subviews directly not affecting top NavigationView, as a result the last kept stand.
class QueryModel: ObservableObject {
#Published var query: String = ""
}
struct ContentView: View {
// No QueryModel environment object here -
// implicitly passed down. !!! MUST !!!
var body: some View {
NavigationView {
ResultsView()
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: SearchItem())
}
}
}
struct ResultsView: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
var body: some View {
VStack {
Text("Search: \(qm.query)") // receive query string
}
}
}
struct SearchItem: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
#State private var query = "" // updates only local view
var body: some View {
let text = Binding(get: { self.query }, set: {
self.query = $0; self.qm.query = $0; // transfer query string
})
return TextField("search", text: text)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(QueryModel())
}
}
I have a view that displays a few photos that are loaded from an API in a scroll view. I want to defer fetching the images until the view is displayed. My view, simplified looks something like this:
struct DetailView : View {
#ObservedObject var viewModel: DetailViewModel
init(viewModel: DetailViewModel) {
self.viewModel = viewModel
}
var body: some View {
GeometryReader { geometry in
ZStack {
Color("peachLight").edgesIgnoringSafeArea(.all)
if self.viewModel.errorMessage != nil {
ErrorView(error: self.viewModel.errorMessage!)
} else if self.viewModel.imageUrls.count == 0 {
VStack {
Text("Loading").foregroundColor(Color("blueDark"))
Text("\(self.viewModel.imageUrls.count)").foregroundColor(Color("blueDark"))
}
} else {
VStack {
UIScrollViewWrapper {
HStack {
ForEach(self.viewModel.imageUrls, id: \.self) { imageUrl in
LoadableImage(url: imageUrl)
.scaledToFill()
}.frame(width: geometry.size.width, height: self.scrollViewHeight)
}.edgesIgnoringSafeArea(.all)
}.frame(width: geometry.size.width, height: self.scrollViewHeight)
Spacer()
}
}
}
}.onAppear(perform: { self.viewModel.fetchDetails() })
.onReceive(viewModel.objectWillChange, perform: {
print("Received new value from view model")
print("\(self.viewModel.imageUrls)")
})
}
}
my view model looks like this:
import Foundation
import Combine
class DetailViewModel : ObservableObject {
#Published var imageUrls: [String] = []
#Published var errorMessage : String?
private var fetcher: Fetchable
private var resourceId : String
init(fetcher: Fetchable, resource: Resource) {
self.resourceId = resource.id
// self.fetchDetails() <-- uncommenting this line results in onReceive being called + a view update
}
// this is a stubbed version of my data fetch that performs the same way as my actual
// data call in regards to ObservableObject updates
// MARK - Data Fetching Stub
func fetchDetails() {
if let path = Bundle.main.path(forResource: "detail", ofType: "json") {
do {
let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)
let parsedData = try JSONDecoder().decode(DetailResponse.self, from: data)
self.imageUrls = parsedData.photos // <-- this doesn't trigger a change, and even manually calling self.objectWillChange.send() here doesn't trigger onReceive/view update
print("setting image urls to \(parsedData.photos)")
} catch {
print("error decoding")
}
}
}
}
If I fetch my data within the init method of my view model, the onReceive block on my view IS called when the #Published imageUrls property is set. However, when I remove the fetch from the init method and call from the view using:
.onAppear(perform: { self.viewModel.fetchDetails() })
the onReceive for viewModel.objectWillChange is NOT called, even though the data is updated. I don't know why this is the case and would really appreciate any help here.
Use instead
.onReceive(viewModel.$imageUrls, perform: { newUrls in
print("Received new value from view model")
print("\(newUrls)")
})
I tested this as I found the same issue, and it seems like only value types can be used with onReceive
use enums, strings, etc.
it doesn't work with reference types because I guess technically a reference type doesn't change reference location and simply points elsewhere when changed? idk haha but ya
as a solution, you can set a viewModel #published property which is like a state enum, make changes to that when you have new data, and then on receive can access that...hope that makes sense, let me know if not
Proper UIKit Approach:
According to Apple's WWDC 2019 talk on the subject, AVPlayerViewController should be presented modally to take advantage of all the latest full-screen features of the API. This is the recommended sample code to be called from your presenting UIKit view controller:
// Create the player
let player = AVPlayer(url: videoURL)
// Create the player view controller and associate the player
let playerViewController = AVPlayerViewController()
playerViewController.player = player
// Present the player view controller modally
present(playerViewController, animated: true)
This works as expected and launches the video in beautiful full-screen.
Achieve the Same Effect with SwiftUI?:
In order to use the AVPlayerViewController from SwiftUI, I created the UIViewControllerRepresentable implementation:
struct AVPlayerView: UIViewControllerRepresentable {
#Binding var videoURL: URL
private var player: AVPlayer {
return AVPlayer(url: videoURL)
}
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.player = player
playerController.player?.play()
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
return AVPlayerViewController()
}
}
I cannot seem to figure out how to present this directly from SwiftUI
in the same way as the AVPlayerViewController is presented directly
from UIKit. My goal is simply to get all of the default, full-screen benefits.
So far, the following has not worked:
If I use a .sheet modifier and present it from within the sheet, then the player is embedded in a sheet and not presented full-screen.
I have also tried to create a custom, empty view controller in UIKit that simply presents my AVPlayerViewController modally from the viewDidAppear method. This gets the player to take on the full screen, but it also shows an empty view controller prior to display the video, which I do not want the user to see.
Any thoughts would be much appreciated!
Just a thought if you like to fullscreen similar like UIKit, did you try the following code from ContentView.
import SwiftUI
import UIKit
import AVKit
struct ContentView: View {
let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
#State private var vURL = URL(string: "https://www.radiantmediaplayer.com/media/bbb-360p.mp4")
var body: some View {
AVPlayerView(videoURL: self.$vURL).transition(.move(edge: .bottom)).edgesIgnoringSafeArea(.all)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct AVPlayerView: UIViewControllerRepresentable {
#Binding var videoURL: URL?
private var player: AVPlayer {
return AVPlayer(url: videoURL!)
}
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.modalPresentationStyle = .fullScreen
playerController.player = player
playerController.player?.play()
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
return AVPlayerViewController()
}
}
The solution explained by Razib-Mollick was a good start for me, but it was missing the use of the SwiftUI .sheet() method. I have added this by adding the following to ContentView:
#State private var showVideoPlayer = false
var body: some View {
Button(action: { self.showVideoPlayer = true }) {
Text("Start video")
}
.sheet(isPresented: $showVideoPlayer) {
AVPlayerView(videoURL: self.$vURL)
.edgesIgnoringSafeArea(.all)
}
}
But the problem is then, that the AVPlayer is instantiated again and again when SwiftUI re-renders the UI.
Therefore the state of the AVPlayer has to move to a class object stored in the environment, so we can get hold of it from the View struct. So my latest solution looks now as follows. I hope it helps somebody else.
class PlayerState: ObservableObject {
public var currentPlayer: AVPlayer?
private var videoUrl : URL?
public func player(for url: URL) -> AVPlayer {
if let player = currentPlayer, url == videoUrl {
return player
}
currentPlayer = AVPlayer(url: url)
videoUrl = url
return currentPlayer!
}
}
struct ContentView: View {
#EnvironmentObject var playerState : PlayerState
#State private var vURL = URL(string: "https://www.radiantmediaplayer.com/media/bbb-360p.mp4")
#State private var showVideoPlayer = false
var body: some View {
Button(action: { self.showVideoPlayer = true }) {
Text("Start video")
}
.sheet(isPresented: $showVideoPlayer, onDismiss: { self.playerState.currentPlayer?.pause() }) {
AVPlayerView(videoURL: self.$vURL)
.edgesIgnoringSafeArea(.all)
.environmentObject(self.playerState)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(PlayerState())
}
}
struct AVPlayerView: UIViewControllerRepresentable {
#EnvironmentObject var playerState : PlayerState
#Binding var videoURL: URL?
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
let playerController = AVPlayerViewController()
playerController.modalPresentationStyle = .fullScreen
playerController.player = playerState.player(for: videoURL!)
playerController.player?.play()
return playerController
}
}
Something to be aware of (a bug?): whenever a modal sheet is shown using .sheet() the environment objects are not automatically passed to the subviews. They have to be added using environmentObject().
Here is a link to read more about this problem: https://oleb.net/2020/sheet-environment/
Xcode 12 · iOS 14
→ Use .fullScreenCover instead of .sheet and you’re good to go.
See also: How to present a full screen modal view using fullScreenCover
To make UI-based editing of a NSAttributedString property (in a managed object) possible, a UITextView is used instead of a SwiftUI TextField View. The text view is located in a modal view being presented by a sheet function.
.sheet(isPresented: $presentSheet) { ...
(to illustrate and reproduce, the code below is a simplified version of this scenario)
The modal view is used to edit a selected model item that is shown in a list through a ForEach construct. The selected model item is passed as an #Observable object to the modal view.
When selecting an item "A", the modal view and the UITextView correctly shows this model item. If selecting a new item "B", the modal view correctly shows this "B" item. But if "B" is now being edited the change will affect the "A" object.
The reason for this behaviour is probably that the UIViewRepresentable view (representing the UITextView) is only initialised once. Further on from here, this seems to be caused by the way a sheet (modal) view is presented in SwiftUI (state variables are only initialised when the sheet first appear, but not the second time).
I am able to fix this malfunction by passing the selected item as a #Binding instead of an #Observable object, although I am not convinced that this is the right way to handle the situation, especially because everything works nicely, if a SwiftUI TextField is used instead of the UITextView (in the simplified case).
Worth mentioning, I seems to have figured out, what goes wrong in the case with the UITextView - without saying that this solves the problem.
In the code listed below (which repro the problem), the Coordinator's init function has one assignment that initialises the Coordinator with the parent. Since this is value and not a reference assignment, and since the Coordinator only get initialised once, an edit of the UITextView will likely access a wrong parent.
Again, I am not certain about my solution to the problem, is the right one, since everything works fine when using a SwiftUI TextField instead. I therefore hope to see some comments on this problem.
struct ContentView: View {
var states = [StringState("A"), StringState("B"), StringState("C"), StringState("D"), StringState("E")]
#State var presentSheet = false
#State var state = StringState("A")
var body: some View {
VStack {
Text("state = \(state.s)")
ForEach(states) { s in
Button(action: {
self.state = s
self.presentSheet.toggle()
})
{
Text("\(s.s)")
}
}
}
.sheet(isPresented: $presentSheet) {
EditView(state: self.state, presentSheet: self.$presentSheet)
}
}
}
struct EditView: View
{
#ObservedObject var state: StringState
#Binding var presentSheet: Bool
var body: some View {
VStack {
Text("\(state.s)")
TextView(string: $state.s) // Edit Not OK
TextField("", text: $state.s ) // Edit OK
Button(action: {
self.presentSheet.toggle()
})
{ Text("Back") }
}
}
}
struct TextView: UIViewRepresentable
{
#Binding var string: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView
{
let textview = UITextView(frame: CGRect.zero)
textview.delegate = context.coordinator
return textview
}
func updateUIView(_ uiView: UITextView, context: Context)
{
uiView.text = string
}
class Coordinator : NSObject, UITextViewDelegate
{
var parent: TextView
init(_ textView: TextView) {
self.parent = textView
}
func textViewDidChange(_ textView: UITextView)
{
self.parent.string = textView.text!
}
}
}
class StringState: Identifiable, ObservableObject
{
let ID = UUID()
var s: String
init(_ s : String) {
self.s = s
}
}
A couple of changes will fix it:
func updateUIView(_ uiView: UITextView, context: Context)
{
uiView.text = string
context.coordinator.parent = self
}
And also add #Published to your ObservableObject:
class StringState: Identifiable, ObservableObject
{
let ID = UUID()
#Published var s: String
init(_ s : String) {
self.s = s
}
}