How to show progress hud while compressing a file in Swift 4? - swift

I'm using marmelroy/Zip framework to zip/unzip files in my project, and JGProgressHUD to show the progress of the operation.
I'm able to see the HUD if I try to show it from the ViewDidLoad method, but if I use it in the closure associated to the progress feature of the quickZipFiles method (like in the code sample), the hud is shown just at the end of the operation.
I guess this could be related to a timing issue, but since I'm not too much into completion handlers, closures and GDC (threads, asynchronous tasks, etc.) I would like to ask for a suggestion.
Any ideas?
// In my class properties declaration
var hud = JGProgressHUD(style: .dark)
// In my ViewDidLoad
self.hud.indicatorView = JGProgressHUDPieIndicatorView()
self.hud.backgroundColor = UIColor(white: 0, alpha: 0.7)
// In my method
do {
self.hud.textLabel.text = NSLocalizedString("Zipping files...", comment: "Zipping File Message")
self.hud.detailTextLabel.text = "0%"
if !(self.hud.isVisible) {
self.hud.show(in: self.view)
}
zipURL = try Zip.quickZipFiles(documentsList, fileName: "documents", progress: { (progress) -> () in
let progressMessage = "\(round(progress*100))%"
print(progressMessage)
self.hud.setProgress(Float(progress), animated: true)
self.hud.textLabel.text = NSLocalizedString("Zipping files...", comment: "Zipping File Message")
self.hud.detailTextLabel.text = progressMessage
if (progress == 1.0) {
self.hud.dismiss()
}
})
} catch {
print("Error while creating zip...")
}

ZIP Foundation comes with built-in support for progress reporting and cancelation.
So if you can switch ZIP library, this might be a better fit for your project. (Full disclosure: I am the author of this library)
Here's some sample code that shows how you can zip a directory and display operation progress on a JGProgressHUD. I just zip the main bundle's directory here as example.
The ZIP operation is dispatched on a separate thread so that your main thread can update the UI. The progress var is a default Foundation (NS)Progress object which reports changes via KVO.
import UIKit
import ZIPFoundation
import JGProgressHUD
class ViewController: UIViewController {
#IBOutlet weak var progressLabel: UILabel!
var indicator = JGProgressHUD()
var isObservingProgress = false
var progressViewKVOContext = 0
#objc
var progress: Progress?
func startObservingProgress()
{
guard !isObservingProgress else { return }
progress = Progress()
progress?.completedUnitCount = 0
self.indicator.progress = 0.0
self.addObserver(self, forKeyPath: #keyPath(progress.fractionCompleted), options: [.new], context: &progressViewKVOContext)
isObservingProgress = true
}
func stopObservingProgress()
{
guard isObservingProgress else { return }
self.removeObserver(self, forKeyPath: #keyPath(progress.fractionCompleted))
isObservingProgress = false
self.progress = nil
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == #keyPath(progress.fractionCompleted) {
DispatchQueue.main.async {
self.indicator.progress = Float(self.progress?.fractionCompleted ?? 0.0)
if let progressDescription = self.progress?.localizedDescription {
self.progressLabel.text = progressDescription
}
if self.progress?.isFinished == true {
self.progressLabel.text = ""
self.indicator.progress = 0.0
}
}
} else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
}
#IBAction func cancel(_ sender: Any) {
self.progress?.cancel()
}
#IBAction func createFullArchive(_ sender: Any) {
let directoryURL = Bundle.main.bundleURL
let tempArchiveURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString).appendingPathExtension("zip")
self.startObservingProgress()
DispatchQueue.global().async {
try? FileManager.default.zipItem(at: directoryURL, to: tempArchiveURL, progress: self.progress)
self.stopObservingProgress()
}
}
}

Looking at the implementation of the zip library, all of the zipping/unzipping and the calls to the progress handlers are being done on the same thread. The example shown on the home page isn't very good and can't be used as-is if you wish to update the UI with a progress indicator while zipping or unzipping.
The solution is to perform the zipping/unzipping in the background and in the progress block, update the UI on the main queue.
Assuming you are calling your posted code from the main queue (in response to the user performing some action), you should update your code as follows:
// In my class properties declaration
var hud = JGProgressHUD(style: .dark)
// In my ViewDidLoad
self.hud.indicatorView = JGProgressHUDPieIndicatorView()
self.hud.backgroundColor = UIColor(white: 0, alpha: 0.7)
self.hud.textLabel.text = NSLocalizedString("Zipping files...", comment: "Zipping File Message")
self.hud.detailTextLabel.text = "0%"
if !(self.hud.isVisible) {
self.hud.show(in: self.view)
}
DispatchQueue.global().async {
defer {
DispatchQueue.main.async {
self.hud.dismiss()
}
}
do {
zipURL = try Zip.quickZipFiles(documentsList, fileName: "documents", progress: { (progress) -> () in
DispatchQueue.main.async {
let progressMessage = "\(round(progress*100))%"
print(progressMessage)
self.hud.setProgress(Float(progress), animated: true)
self.hud.textLabel.text = NSLocalizedString("Zipping files...", comment: "Zipping File Message")
self.hud.detailTextLabel.text = progressMessage
}
})
} catch {
print("Error while creating zip...")
}
}

Related

Progress Bar doesnt animate in UIKIt, Swift

I am using PHPicker for the first time, using loadFileRepresentation method, which asynchronously writes a copy of a selected file and returns a progress object. I am attaching this progress object to a parent progress where I track progress of copying of all files selected by user, so that I can drive one single progress bar view.
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
var tasks = [Progress]()
let parentTask = Progress()
DispatchQueue.global(qos: .unspecified).async {
for itemProvider in results.map({ $0.itemProvider }) {
if itemProvider.canLoadObject(ofClass: UIImage.self) {
guard let identifier = itemProvider.registeredTypeIdentifiers.first else { return }
guard let filenameExtension = URL(string: identifier)?.pathExtension else { return }
let newTask = itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { tempPathForFileCopying, error in
if (error != nil) {
print("Error while copying files \(String(describing: error))")
}
let targetPath = self.viewModel.galleryManager.selectedGalleryPath.appendingPathComponent(UUID().uuidString).appendingPathExtension(filenameExtension)
if let tempPathForFileCopying {
do {
try FileManager.default.copyItem(at: tempPathForFileCopying, to: targetPath)
} catch {
print("Error \(error)")
}
self.viewModel.galleryManager.buildThumb(forImage: AlbumImage(fileName: targetPath.lastPathComponent, date: Date()))
self.imagesToBeAdded.append(AlbumImage(fileName: targetPath.lastPathComponent, date: Date()))
}
}
tasks.append(newTask)
parentTask.addChild(newTask, withPendingUnitCount: newTask.totalUnitCount - newTask.completedUnitCount)
}
}
}
for task in tasks {
parentTask.totalUnitCount += task.totalUnitCount
}
self.screenView.progressView.observedProgress = parentTask
self.showLoading(task: parentTask)
}
I than invoke showLoading function, which should end up in while loop, until task.isFinished is true. Strangely, printing progress into the console works fine, and I can see that progress there, but no matter what, I cannot update UI progress bar view from here. Entire UI is stuck, until all the copies of the files are created. Even though that happens on background thread and I am trying to call progressView.setProgress on main thread. Again, printing current progress into the console works fine, but no matter what, UI doesnt update until all that copying is done, which just by that time is useless
func showLoading(task: Progress) {
DispatchQueue.main.async {
while !task.isFinished {
var oldFraction = task.fractionCompleted
usleep(300)
if task.fractionCompleted != oldFraction {
let progress = Float(task.fractionCompleted)
self.screenView.progressView.setProgress(progress, animated: false)
print(progress)
}
}
self.viewModel.addPhotos(images: self.imagesToBeAdded)
}
}

How can I add multipeer connectivity to an ARKit app that doesn't have 3D assets, but uses UITextView for rendering instead? [ Swift ]

So I am trying to add a multipeer element to this Sticky Note app from Apple's own Sample Code. Link to Sample Code page There are several examples of multipeer ARKit apps but the problem here is, with the app I am working from, the Sticky Note is NOT a 3D element but
For the purposes of this sample app, the sticky note entity has no geometry and thus, no appearance. Its anchor provides a 3D location only, and itʼs the sticky noteʼs screen-space annotation that has an appearance. To display it, you define the sticky noteʼs annotation. Following RealityKitʼs entity-component model, design a component that houses the annotation, which in this case is a view. See ScreenSpaceComponent.
I have been trying to use the example of multipeer apps in ARthat use the ARKit element with 3D elements stored as either assets [the "Collaborative Session" example ] or using ModelEntity geometry [the Creating a Multiuser AR Experience example ] but I haven't been successful in translating this app which uses screen space only.
I am able to get the message on the screen that it's connected to a peer, but that is as far as it goes. It will not render the notes on the second phone. I am burned out from all the attempts of making it work:(
One alternative is to forget about the notes being tethered to the screen space, and recreating this as a regular 3D space and 2D geometry thing using SpriteKit.
The system will not render the apps sticky notes on the other phone. I know there is a way around this, but I have been trying for days and haven't been able to do it.
I have been testing this using 2 phones.
I have
Added the info on the p.list
Added the Multipeer Session file
Added the code on the ViewController file related to multipeer
Added code to the arGestureSetUp() extension file which has the rendering info for the sticky notes.
What works: I can see the notes on both phones, and I get the messages saying that a peer has joined. What I can't do is view the
other user's notes like I would in a regular 3D ARkit app. It will not
render.
This is what I have added to the insertNewSticky function
func insertNewSticky(_ sender: UITapGestureRecognizer)
from one of the other examples:
let anchor = ARAnchor(name: "Anchor for object placement", transform: raycastResult.worldTransform)
arView.session.add(anchor: anchor)
Below is the full code for the Gesture Recognizer Setup
import UIKit
import ARKit
extension ViewController {
// MARK: - Gesture recognizer setup
// - Tag: AddViewTapGesture
func arViewGestureSetup() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tappedOnARView))
arView.addGestureRecognizer(tapGesture)
let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipedDownOnARView))
swipeGesture.direction = .down
arView.addGestureRecognizer(swipeGesture)
}
func stickyNoteGestureSetup(_ note: StickyNoteEntity) {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panOnStickyView))
note.view?.addGestureRecognizer(panGesture)
let tapOnStickyView = UITapGestureRecognizer(target: self, action: #selector(tappedOnStickyView(_:)))
note.view?.addGestureRecognizer(tapOnStickyView)
}
// MARK: - Gesture recognizer callbacks
/// Tap gesture input handler.
/// - Tag: TapHandler
#objc
func tappedOnARView(_ sender: UITapGestureRecognizer) {
// Ignore the tap if the user is editing a sticky note.
for note in stickyNotes where note.isEditing { return }
// Create a new sticky note at the tap location.
insertNewSticky(sender)
}
/**
Hit test the feature point cloud and use any hit as the position of a new StickyNote. Otherwise, display a tip.
- Tag: ScreenSpaceViewInsertionTag
*/
func insertNewSticky(_ sender: UITapGestureRecognizer) {
// Get the user's tap screen location.
let touchLocation = sender.location(in: arView)
// Cast a ray to check for its intersection with any planes.
guard let raycastResult = arView.raycast(from: touchLocation, allowing: .estimatedPlane, alignment: .any).first
else {
messageLabel.displayMessage("No surface detected, try getting closer.", duration: 2.0)
return
}
// Create a new sticky note positioned at the hit test result's world position.
let frame = CGRect(origin: touchLocation, size: CGSize(width: 200, height: 200))
let note = StickyNoteEntity(frame: frame, worldTransform: raycastResult.worldTransform)
// Center the sticky note's view on the tap's screen location.
note.setPositionCenter(touchLocation)
// Add the sticky note to the scene's entity hierarchy.
arView.scene.addAnchor(note)
// Add the sticky note's view to the view hierarchy.
guard let stickyView = note.view else { return }
arView.insertSubview(stickyView, belowSubview: trashZone)
// Enable gestures on the sticky note.
stickyNoteGestureSetup(note)
// Save a reference to the sticky note.
stickyNotes.append(note)
// Volunteer to handle text view callbacks.
stickyView.textView.delegate = self
let anchor = ARAnchor(name: "Anchor for object placement", transform: raycastResult.worldTransform)
arView.session.add(anchor: anchor)
}
/// Dismisses the keyboard.
#objc
func swipedDownOnARView(_ sender: UISwipeGestureRecognizer) {
dismissKeyboard()
}
fileprivate func dismissKeyboard() {
for note in stickyNotes {
guard let textView = note.view?.textView else { continue }
if textView.isFirstResponder {
textView.resignFirstResponder()
return
}
}
}
#objc
func tappedOnStickyView(_ sender: UITapGestureRecognizer) {
guard let stickyView = sender.view as? StickyNoteView else { return }
stickyView.textView.becomeFirstResponder()
}
//- Tag: PanOnStickyView
fileprivate func panStickyNote(_ sender: UIPanGestureRecognizer, _ stickyView: StickyNoteView, _ panLocation: CGPoint) {
messageLabel.isHidden = true
let feedbackGenerator = UIImpactFeedbackGenerator()
switch sender.state {
case .began:
// Prepare the taptic engine to reduce latency in delivering feedback.
feedbackGenerator.prepare()
// Drag if the gesture is beginning.
stickyView.stickyNote.isDragging = true
// Save offsets to implement smooth panning.
guard let frame = sender.view?.frame else { return }
stickyView.xOffset = panLocation.x - frame.origin.x
stickyView.yOffset = panLocation.y - frame.origin.y
// Fade in the widget that's used to delete sticky notes.
trashZone.fadeIn(duration: 0.4)
case .ended:
// Stop dragging if the gesture is ending.
stickyView.stickyNote.isDragging = false
// Delete the sticky note if the gesture ended on the trash widget.
if stickyView.isInTrashZone {
deleteStickyNote(stickyView.stickyNote)
// ...
} else {
attemptRepositioning(stickyView)
}
// Fades out the widget that's used to delete sticky notes when there are no sticky notes currently being dragged.
if !stickyNotes.contains(where: { $0.isDragging }) {
trashZone.fadeOut(duration: 0.2)
}
default:
// Update the sticky note's screen position based on the pan location, and initial offset.
stickyView.frame.origin.x = panLocation.x - stickyView.xOffset
stickyView.frame.origin.y = panLocation.y - stickyView.yOffset
// Give feedback whenever the pan location is near the widget used to delete sticky notes.
trashZoneThresholdFeedback(sender, feedbackGenerator)
}
}
/// Sticky note pan-gesture handler.
/// - Tag: PanHandler
#objc
func panOnStickyView(_ sender: UIPanGestureRecognizer) {
guard let stickyView = sender.view as? StickyNoteView else { return }
let panLocation = sender.location(in: arView)
// Ignore the pan if any StickyViews are being edited.
for note in stickyNotes where note.isEditing { return }
panStickyNote(sender, stickyView, panLocation)
}
func deleteStickyNote(_ note: StickyNoteEntity) {
guard let index = stickyNotes.firstIndex(of: note) else { return }
note.removeFromParent()
stickyNotes.remove(at: index)
note.view?.removeFromSuperview()
note.view?.isInTrashZone = false
}
/// - Tag: AttemptRepositioning
fileprivate func attemptRepositioning(_ stickyView: StickyNoteView) {
// Conducts a ray-cast for feature points using the panned position of the StickyNoteView
let point = CGPoint(x: stickyView.frame.midX, y: stickyView.frame.midY)
if let result = arView.raycast(from: point, allowing: .estimatedPlane, alignment: .any).first {
stickyView.stickyNote.transform.matrix = result.worldTransform
} else {
messageLabel.displayMessage("No surface detected, unable to reposition note.", duration: 2.0)
stickyView.stickyNote.shouldAnimate = true
}
}
fileprivate func trashZoneThresholdFeedback(_ sender: UIPanGestureRecognizer, _ feedbackGenerator: UIImpactFeedbackGenerator) {
guard let stickyView = sender.view as? StickyNoteView else { return }
let panLocation = sender.location(in: trashZone)
if trashZone.frame.contains(panLocation), !stickyView.isInTrashZone {
stickyView.isInTrashZone = true
feedbackGenerator.impactOccurred()
} else if !trashZone.frame.contains(panLocation), stickyView.isInTrashZone {
stickyView.isInTrashZone = false
feedbackGenerator.impactOccurred()
}
}
#objc
func tappedReset(_ sender: UIButton) {
reset()
}
}
and this is the full code for the ViewController file
/*
See LICENSE folder for this sample’s licensing information.
Abstract:
Main view controller for the AR experience.
*/
import UIKit
import RealityKit
import Combine
import ARKit
import MultipeerConnectivity
class ViewController: UIViewController, ARSessionDelegate {
// MARK: - Class variable declarations
#IBOutlet var arView: ARView!
#IBOutlet weak var messageLabel: MessageLabel!
var trashZone: GradientView!
var shadeView: UIView!
var resetButton: UIButton!
var keyboardHeight: CGFloat!
var stickyNotes = [StickyNoteEntity]()
var subscription: Cancellable!
//added Sat May 28 5:12pm
var multipeerSession: MultipeerSession?
// end of added Sat May 28 5:12pm
//added Sat May 28 5:12pm
// A dictionary to map MultiPeer IDs to ARSession ID's.
// This is useful for keeping track of which peer created which ARAnchors.
var peerSessionIDs = [MCPeerID: String]()
var sessionIDObservation: NSKeyValueObservation?
var configuration: ARWorldTrackingConfiguration?
// end of added Sat May 28 5:12pm
// MARK: - View Controller Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
subscription = arView.scene.subscribe(to: SceneEvents.Update.self) { [unowned self] in
self.updateScene(on: $0)
}
arViewGestureSetup()
overlayUISetup()
arView.session.delegate = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Add observer to the keyboardWillShowNotification to get the height of the keyboard every time it is shown
let notificationName = UIResponder.keyboardWillShowNotification
let selector = #selector(keyboardIsPoppingUp(notification:))
NotificationCenter.default.addObserver(self, selector: selector, name: notificationName, object: nil)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
arView.session.delegate = self
// Prevent the screen from being dimmed to avoid interuppting the AR experience.
UIApplication.shared.isIdleTimerDisabled = true
// Turn off ARView's automatically-configured session
// to create and set up your own configuration.
arView.automaticallyConfigureSession = false
configuration = ARWorldTrackingConfiguration()
// Enable a collaborative session.
configuration?.isCollaborationEnabled = true
// Enable realistic reflections.
configuration?.environmentTexturing = .automatic
// Begin the session.
arView.session.run(configuration!)
// Use key-value observation to monitor your ARSession's identifier.
sessionIDObservation = observe(\.arView.session.identifier, options: [.new]) { object, change in
print("SessionID changed to: \(change.newValue!)")
// Tell all other peers about your ARSession's changed ID, so
// that they can keep track of which ARAnchors are yours.
guard let multipeerSession = self.multipeerSession else { return }
self.sendARSessionIDTo(peers: multipeerSession.connectedPeers)
}
// Start looking for other players via MultiPeerConnectivity.
multipeerSession = MultipeerSession(receivedDataHandler: receivedData, peerJoinedHandler:
peerJoined, peerLeftHandler: peerLeft, peerDiscoveredHandler: peerDiscovered)
//arView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap(recognizer:))))
messageLabel.displayMessage("Tap the screen to place cubes.\nInvite others to launch this app to join you.", duration: 60.0)
}
//peerDiscovered
func peerDiscovered(_ peer: MCPeerID) -> Bool {
guard let multipeerSession = multipeerSession else { return false }
if multipeerSession.connectedPeers.count > 3 {
// Do not accept more than four users in the experience.
messageLabel.displayMessage("A fifth peer wants to join the experience.\nThis app is limited to four users.", duration: 6.0)
return false
} else {
return true
}
}
// end of added Sat May 28 5:12pm
/// - Tag: PeerJoined
// added Sat May 28 5:12pm
func peerJoined(_ peer: MCPeerID) {
messageLabel.displayMessage("""
A peer has joined the experience.
Hold the phones next to each other.
""", duration: 6.0)
// Provide your session ID to the new user so they can keep track of your anchors.
sendARSessionIDTo(peers: [peer])
}
// end of added Sat May 28 5:12pm
// added Sat May 28 5:12pm
func peerLeft(_ peer: MCPeerID) {
messageLabel.displayMessage("A peer has left the shared experience.")
// Remove all ARAnchors associated with the peer that just left the experience.
if let sessionID = peerSessionIDs[peer] {
removeAllAnchorsOriginatingFromARSessionWithID(sessionID)
peerSessionIDs.removeValue(forKey: peer)
}
}
// end of added Sat May 28 5:12pm
//added Sat May 28 5:12pm
func receivedData(_ data: Data, from peer: MCPeerID) {
if let collaborationData = try? NSKeyedUnarchiver.unarchivedObject(ofClass: ARSession.CollaborationData.self, from: data) {
arView.session.update(with: collaborationData)
return
}
// ...
let sessionIDCommandString = "SessionID:"
if let commandString = String(data: data, encoding: .utf8), commandString.starts(with: sessionIDCommandString) {
let newSessionID = String(commandString[commandString.index(commandString.startIndex,
offsetBy: sessionIDCommandString.count)...])
// If this peer was using a different session ID before, remove all its associated anchors.
// This will remove the old participant anchor and its geometry from the scene.
if let oldSessionID = peerSessionIDs[peer] {
removeAllAnchorsOriginatingFromARSessionWithID(oldSessionID)
}
peerSessionIDs[peer] = newSessionID
}
}
// end of added Sat May 28 5:12pm
func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
messageLabel.displayMessage("Established joint experience with a peer.")
// ...
}
func updateScene(on event: SceneEvents.Update) {
let notesToUpdate = stickyNotes.compactMap { !$0.isEditing && !$0.isDragging ? $0 : nil }
for note in notesToUpdate {
// Gets the 2D screen point of the 3D world point.
guard let projectedPoint = arView.project(note.position) else { return }
// Calculates whether the note can be currently visible by the camera.
let cameraForward = arView.cameraTransform.matrix.columns.2.xyz
let cameraToWorldPointDirection = normalize(note.transform.translation - arView.cameraTransform.translation)
let dotProduct = dot(cameraForward, cameraToWorldPointDirection)
let isVisible = dotProduct < 0
// Updates the screen position of the note based on its visibility
note.projection = Projection(projectedPoint: projectedPoint, isVisible: isVisible)
note.updateScreenPosition()
}
}
func reset() {
guard let configuration = arView.session.configuration else { return }
arView.session.run(configuration, options: .removeExistingAnchors)
for note in stickyNotes {
deleteStickyNote(note)
}
}
func session(_ session: ARSession, didFailWithError error: Error) {
guard error is ARError else { return }
let errorWithInfo = error as NSError
let messages = [
errorWithInfo.localizedDescription,
errorWithInfo.localizedFailureReason,
errorWithInfo.localizedRecoverySuggestion
]
let errorMessage = messages.compactMap({ $0 }).joined(separator: "\n")
DispatchQueue.main.async {
// Present an alert informing about the error that has occurred.
let alertController = UIAlertController(title: "The AR session failed.", message: errorMessage, preferredStyle: .alert)
let restartAction = UIAlertAction(title: "Restart Session", style: .default) { _ in
alertController.dismiss(animated: true, completion: nil)
self.reset()
}
alertController.addAction(restartAction)
self.present(alertController, animated: true, completion: nil)
}
}
override var prefersStatusBarHidden: Bool {
return true
}
override var prefersHomeIndicatorAutoHidden: Bool {
return true
}
private func sendARSessionIDTo(peers: [MCPeerID]) {
guard let multipeerSession = multipeerSession else { return }
let idString = arView.session.identifier.uuidString
let command = "SessionID:" + idString
if let commandData = command.data(using: .utf8) {
multipeerSession.sendToPeers(commandData, reliably: true, peers: peers)
}
}
private func removeAllAnchorsOriginatingFromARSessionWithID(_ identifier: String) {
guard let frame = arView.session.currentFrame else { return }
for anchor in frame.anchors {
guard let anchorSessionID = anchor.sessionIdentifier else { continue }
if anchorSessionID.uuidString == identifier {
arView.session.remove(anchor: anchor)
}
}
}
}
Update: I spoke to a Staff Engineer on Apple's RealityKit team who explained to me that what I was trying to accomplish is not feasible because the note had an embedded subclass that is not 'codable' as per Swift's Codable Protocol
I will have to rebuild the note differently than the example i had been working with to ensure it fits within the Codable protocol which will then ensure the data can travel across the network via Multipeer Connectivity Framework.

How to do Apple Watch Background App Refresh?

I've consulted many variations of background app refresh for Apple Watch so that I can update the complications for my app. However, the process seems very much hit or miss and most of the time it doesn't run at all after some time.
Here is the code I currently have:
BackgroundService.swift
Responsibility: Schedule background refresh and handle download processing and update complications.
import Foundation
import WatchKit
final class BackgroundService: NSObject, URLSessionDownloadDelegate {
var isStarted = false
private let requestFactory: RequestFactory
private let logManager: LogManager
private let complicationService: ComplicationService
private let notificationService: NotificationService
private var pendingBackgroundTask: WKURLSessionRefreshBackgroundTask?
private var backgroundSession: URLSession?
init(requestFactory: RequestFactory,
logManager: LogManager,
complicationService: ComplicationService,
notificationService: NotificationService
) {
self.requestFactory = requestFactory
self.logManager = logManager
self.complicationService = complicationService
self.notificationService = notificationService
super.init()
NotificationCenter.default.addObserver(self,
selector: #selector(handleInitialSchedule(_:)),
name: Notification.Name("ScheduleBackgroundTasks"),
object: nil
)
}
func updateContent() {
self.logManager.debugMessage("In BackgroundService updateContent")
let complicationsUpdateRequest = self.requestFactory.makeComplicationsUpdateRequest()
let config = URLSessionConfiguration.background(withIdentifier: "app.wakawatch.background-refresh")
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
self.backgroundSession = URLSession(configuration: config,
delegate: self,
delegateQueue: nil)
let backgroundTask = self.backgroundSession?.downloadTask(with: complicationsUpdateRequest)
backgroundTask?.resume()
self.isStarted = true
self.logManager.debugMessage("backgroundTask started")
}
func handleDownload(_ backgroundTask: WKURLSessionRefreshBackgroundTask) {
self.logManager.debugMessage("Handling finished download")
self.pendingBackgroundTask = backgroundTask
}
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
processFile(file: location)
self.logManager.debugMessage("Marking pending background tasks as completed.")
if self.pendingBackgroundTask != nil {
self.pendingBackgroundTask?.setTaskCompletedWithSnapshot(false)
self.backgroundSession?.invalidateAndCancel()
self.pendingBackgroundTask = nil
self.backgroundSession = nil
self.logManager.debugMessage("Pending background task cleared")
}
self.schedule()
}
func processFile(file: URL) {
guard let data = try? Data(contentsOf: file) else {
self.logManager.errorMessage("file could not be read as data")
return
}
guard let backgroundUpdateResponse = try? JSONDecoder().decode(BackgroundUpdateResponse.self, from: data) else {
self.logManager.errorMessage("Unable to decode response to Swift object")
return
}
let defaults = UserDefaults.standard
defaults.set(backgroundUpdateResponse.totalTimeCodedInSeconds,
forKey: DefaultsKeys.complicationCurrentTimeCoded)
self.complicationService.updateTimelines()
self.notificationService.isPermissionGranted(onGrantedHandler: {
self.notificationService.notifyGoalsAchieved(newGoals: backgroundUpdateResponse.goals)
})
self.logManager.debugMessage("Complication updated")
}
func schedule() {
let time = self.isStarted ? 15 * 60 : 60
let nextInterval = TimeInterval(time)
let preferredDate = Date.now.addingTimeInterval(nextInterval)
WKExtension.shared().scheduleBackgroundRefresh(withPreferredDate: preferredDate,
userInfo: nil) { error in
if error != nil {
self.logManager.reportError(error!)
return
}
self.logManager.debugMessage("Scheduled for \(preferredDate)")
}
}
#objc func handleInitialSchedule(_ notification: NSNotification) {
if !self.isStarted {
self.schedule()
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
The flow for the above file's usage is that it will be used by the ExtensionDelegate to schedule background refresh. The first time, it'll schedule a refresh for 1 minute out and then every 15 minutes after that.
Here is the ExtensionDelegate:
import Foundation
import WatchKit
class ExtensionDelegate: NSObject, WKExtensionDelegate {
private var backgroundService: BackgroundService?
private var logManager: LogManager?
override init() {
super.init()
self.backgroundService = DependencyInjection.shared.container.resolve(BackgroundService.self)!
self.logManager = DependencyInjection.shared.container.resolve(LogManager.self)!
}
func isAuthorized() -> Bool {
let defaults = UserDefaults.standard
return defaults.bool(forKey: DefaultsKeys.authorized)
}
func applicationDidFinishLaunching() {
self.logManager?.debugMessage("In applicationDidFinishLaunching")
if isAuthorized() && !(self.backgroundService?.isStarted ?? false) {
self.backgroundService?.schedule()
}
}
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
self.logManager?.debugMessage("In handle backgroundTasks")
if !isAuthorized() {
return
}
for task in backgroundTasks {
self.logManager?.debugMessage("Processing task: \(task.debugDescription)")
switch task {
case let backgroundTask as WKApplicationRefreshBackgroundTask:
self.backgroundService?.updateContent()
backgroundTask.setTaskCompletedWithSnapshot(false)
case let urlSessionTask as WKURLSessionRefreshBackgroundTask:
self.backgroundService?.handleDownload(urlSessionTask)
default:
task.setTaskCompletedWithSnapshot(false)
}
}
}
}
When the app is launched from background, it'll try to schedule for the first time in applicationDidFinishLaunching.
From my understanding, WKExtension.shared().scheduleBackgroundRefresh will get called in schedule, then after the preferred time WatchOS will call handle with WKApplicationRefreshBackgroundTask task. Then I will use that to schedule a background URL session task and immediately start it as seen in the updateContent method of BackgroundService. After some time, WatchOS will then call ExtensionDelegate's handle method with WKURLSessionRefreshBackgroundTask and I'll handle that using the handleDownload task. In there, I process the response, update the complications, clear the task, and finally schedule a new background refresh.
I've found it works great if I'm actively working on the app or interacting with it in general. But let's say I go to sleep then the next day the complication will not have updated at all.
Ideally, I'd like for it to function as well as the Weather app complication WatchOS has. I don't interact with the complication, but it reliably updates.
Is the above process correct or are there any samples of correct implementations?
Some of the posts I've consulted:
https://wjwickham.com/posts/refreshing-data-in-the-background-on-watchOS/
https://developer.apple.com/documentation/watchkit/background_execution/using_background_tasks
https://spin.atomicobject.com/2021/01/26/complications-basic-functionality/

Background thread Core Data object property changes doesn't reflect on UI

Let say I want to add a new item in Playlist entity of CoreData and put it in background thread and push back it to main thread then reflect it on tableView. Well, that code is working fine without background thread implementation.
But when I apply below background kinda code, after createPlaylist is executed, tableView becomes to empty space(without any items showed up), though print(self?.playlists.count) gives the correct rows count.
When dealing with GCD, I put some heavy code in background queue and push back to main queue for UI update in same closure. But it seems not worked here, I google a quit of time but still cannot anchor the issue.
import UIKit
import CoreData
class PlayListViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
var songs = [Song]()
var position = 0
let container = (UIApplication.shared.delegate as! AppDelegate).persistentContainer
private var playlists = [Playlist]()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 1, alpha: 1)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "playlistCell")
configureLayout()
getAllPlaylists()
}
// MARK: Core data functions
func getAllPlaylists() {
do {
let context = self.container.viewContext
playlists = try context.fetch(Playlist.fetchRequest())
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
}
print("count: \(playlists.count)")
// printThreadStats()
} catch {
print("getAllPlaylists failed, \(error)")
}
}
func createPlaylist(name: String) {
container.performBackgroundTask { context in
let newPlaylist = Playlist(context: context)
newPlaylist.name = name
do {
try context.save()
self.playlists = try context.fetch(Playlist.fetchRequest())
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
print(self?.playlists.count)
}
} catch {
print("Create playlist failed, \(error)")
}
}
}
// MARK: tableView data source implementation
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return playlists.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let playlist = playlists[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "playlistCell", for: indexPath)
cell.textLabel?.text = playlist.name
// cell.detailTextLabel?.text = "2 songs"
return cell
}
auto generated fetchRequest and Property defining
import Foundation
import CoreData
extension Playlist {
#nonobjc public class func fetchRequest() -> NSFetchRequest<Playlist> {
return NSFetchRequest<Playlist>(entityName: "Playlist")
}
#NSManaged public var name: String?
}
For the first call of func getAllPlaylists(), you are calling this on main thread from viewDidLoad(). So following lines are executed on main thread.
let context = self.container.viewContext
playlists = try context.fetch(Playlist.fetchRequest())
Next time inside the createPlaylist method, you are performing add playlist task in background context (not on main thread). So following lines are executed on background thread.
self.playlists = try context.fetch(Playlist.fetchRequest())
Also note that, first time we are using viewContext to fetch playlists and second time a backgroundContext. This mix up causes the UI to not show expected result.
I think these two methods could be simplified to -
func getAllPlaylists() {
do {
let context = self.container.viewContext
playlists = try context.fetch(Playlist.fetchRequest())
// DispatchQueue.main.async not necessary, we are already on main thread
self.tableView.reloadData()
print("count: \(playlists.count)")
} catch {
print("getAllPlaylists failed, \(error)")
}
}
func createPlaylist(name: String) {
container.performBackgroundTask { context in
let newPlaylist = Playlist(context: context)
newPlaylist.name = name
do {
try context.save()
DispatchQueue.main.async { [weak self] in
self?.getAllPlaylists()
}
} catch {
print("Create playlist failed, \(error)")
}
}
}
After 5 hours' digging today, I found the solution. I'd like put my solution and code below, because the stuff about "How to pass NSManagedObject instances between queues in CoreData" is quite rare && fragmentation, not friendly to newbies of SWIFT.
The thing is we want to do heavy CoreData task on background thread and reflect the changes in UI on foreground(main thread). Generally, we need to create a private queue context(privateMOC) and perform the heavy CoreData task on this private context, see below code.
For reuse purpose, I put CoreData functions separately.
import UIKit
import CoreData
struct CoreDataManager {
let managedObjectContext: NSManagedObjectContext
private let privateMOC = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
let coreDataStack = CoreDataStack()
static let shared = CoreDataManager()
private init() {
self.managedObjectContext = coreDataStack.persistentContainer.viewContext
privateMOC.parent = self.managedObjectContext
}
func fetchAllPlaylists(completion: #escaping ([Playlist]?) -> Void) {
privateMOC.performAndWait {
do {
let playlists: [Playlist] = try privateMOC.fetch(Playlist.fetchRequest())
print("getAllPlaylists")
printThreadStats()
print("count: \(playlists.count)")
completion(playlists)
} catch {
print("fetchAllPlaylists failed, \(error), \(error.localizedDescription)")
completion(nil)
}
}
}
func createPlaylist(name: String) {
privateMOC.performAndWait {
let newPlaylist = Playlist(context: privateMOC)
newPlaylist.name = name
synchronize()
}
}
func deletePlaylist(playlist: Playlist) {
privateMOC.performAndWait {
privateMOC.delete(playlist)
synchronize()
}
}
func updatePlaylist(playlist: Playlist, newName: String) {
...
}
func removeAllFromEntity(entityName: String) {
...
}
func synchronize() {
do {
// We call save on the private context, which moves all of the changes into the main queue context without blocking the main queue.
try privateMOC.save()
managedObjectContext.performAndWait {
do {
try managedObjectContext.save()
} catch {
print("Could not synchonize data. \(error), \(error.localizedDescription)")
}
}
} catch {
print("Could not synchonize data. \(error), \(error.localizedDescription)")
}
}
func printThreadStats() {
if Thread.isMainThread {
print("on the main thread")
} else {
print("off the main thread")
}
}
}
And Apple has a nice template for it Using a Private Queue to Support Concurrency
Another helpful link: Best practice: Core Data Concurrency
The real tricky thing is how to connect it with your view or viewController, the really implementation. See below ViewController code.
// 1
override func viewDidLoad() {
super.viewDidLoad()
// some layout code
// execute on background thread
DispatchQueue.global().async { [weak self] in
self?.fetchAndReload()
}
}
// 2
private func fetchAndReload() {
CoreDataManager.shared.fetchAllPlaylists(completion: { playlists in
guard let playlists = playlists else { return }
self.playlists = playlists
})
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
// 3
#objc func createNewPlaylist(_ sender: Any?) {
let ac = UIAlertController(title: "Create New Playlist", message: "", preferredStyle: .alert)
ac.addTextField { textField in
textField.placeholder = "input your desired name"
}
ac.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
ac.addAction(UIAlertAction(title: "Done", style: .default, handler: { [weak self] _ in
guard let textField = ac.textFields?.first, let newName = textField.text, !newName.isEmpty else { return }
// check duplicate
if let playlists = self?.playlists {
if playlists.contains(where: { playlist in
playlist.name == newName
}) {
self?.duplicateNameAlert()
return
}
}
DispatchQueue.global().async { [weak self] in
CoreDataManager.shared.createPlaylist(name: newName)
self?.fetchAndReload()
}
}))
present(ac, animated: true)
}
Let me break down it:
First in viewDidload, we call fetchAndReload on background thread.
In fetchAndReload function, it brings out all the playlist(returns data with completion handler) and refresh the table on main thread.
We call createPlaylist(name: newName) in background thread and reload the table on main thread again.
Well, this is the 1st time I deal with Multi-threading in CoreData, if there is any mistake, please indicate it. Allright, that's it! Hope it could help someone.

Game center Authentication not working

My Game Center Authentication is not working. When I build and run, it won't show my username.. has signed in. Also, when I try to add my score I get a screen that says "no data availible". Heres my code.
override func viewDidLoad() {
super.viewDidLoad()
gcAuthPlayer()
}
#IBAction func GCButton(sender: AnyObject) {
saveHighScore(GameScene().highScoreNumer)
showLeaderBoard()
if GameScene().currentScore > GameScene().highScoreNumer{
saveHighScore(GameScene().currentScore)
}
}
func showLeaderBoard(){
let viewController = self.view.window?.rootViewController
let gcvc = GKGameCenterViewController()
gcvc.gameCenterDelegate = self
viewController?.presentViewController(gcvc, animated: true, completion: nil)
}
func saveHighScore(number: Int){
if GKLocalPlayer.localPlayer().authenticated{
let scoreReporter = GKScore(leaderboardIdentifier: "myleaderboard")
scoreReporter.value = Int64(number)
let scoreArray : [GKScore] = [scoreReporter]
GKScore.reportScores(scoreArray, withCompletionHandler: nil)
}
}
func gcAuthPlayer(){
let localPlayer = GKLocalPlayer.localPlayer()
localPlayer.authenticateHandler = {
(view, error) in
if view != nil{
self.presentViewController(view!, animated: true, completion: nil)
}else{
print(GKLocalPlayer.localPlayer().authenticated)
}
}
}
func gameCenterViewControllerDidFinish(gameCenterViewController: GKGameCenterViewController) {
gameCenterViewController.dismissViewControllerAnimated(true, completion: nil)
}
This code makes no sense
saveHighScore(GameScene().highScoreNumer)
showLeaderBoard()
if GameScene().currentScore > GameScene().highScoreNumer{
saveHighScor
You are creating a new instance of GameScene everytime you try to update the score and therefore your score is nil
I would need to see some more code but for now you need to change the score property in your game scene. For example make it a static property so you can get it in other classes.
class GameScene: SKScene {
static var currentScore = 0
static var highscoreNumber = 0
}
Than in your Scenes or ViewController you can get it like so
GameScene.currentScore = 5
GameScene.highscoreNumber = 5
Just remember that you have to reset the score to 0 everytime you restart your gameScene because it a static property.
GameScene.currentScore = 0
GameScene.highscoreNumber = 0
Than your code to post the score should look like this
saveHighScore(GameScene.highScoreNumer)
showLeaderBoard()
if GameScene.currentScore > GameScene.highScoreNumer{
saveHighScor
Your score reporting code should also handle the error and actually do the completion handler. So change it to something like this.
/// Save leaderboard progress
func reportLeaderboardProgress(value: Int, leaderboardID: String) {
let scoreReporter = GKScore(leaderboardIdentifier: leaderboardID)
scoreReporter.value = Int64(value)
GKScore.reportScores([scoreReporter]) { error in // Trailing Closure syntax
if let error = error {
print(error.localizedDescription)
return
}
print("Reported leaderboard progress \(value) to leaderboardID \(leaderboardID)")
}
}
It is also a good idea to move that code into another class to keep your overall code cleaner and more reusable.
For a nice and simple example check this helper on gitHub.
https://github.com/jackcook/GCHelper
Let me know how it goes.