Custom Activity Indicator with Swift - swift

I am trying to create a custom activity indicator like one of these. There seems to be many approaches to animating the image, so I'm trying to figure out the best approach.
I found this tutorial with Photoshop + Aftereffects animation loop, but as the comment points out, it seems overly complex (and I don't own After Effects).
tldr: how can I take my existing image and animate it as a activity indicator (using either rotating/spinning animation or looping through an animation)

It's been a while since you posted this question, so I'm not sure if you've found the answer you're looking for.
You are right there are many ways to do what you are asking, and I don't think there's a "right way". I would make a UIView object which have a start and a stop method as the only public methods. I would also make it a singleton, so it's a shared object and not possible to put multiple instances on a UIViewController (imagine the mess it could make).
You can add a dataSource protocol which returns a UIViewController so the custom activity indicator could add itself to it's parent.
In the start method I would do whatever animations is needed (transform, rotate, GIF, etc.).
Here's an example:
import UIKit
protocol CustomActivityIndicatorDataSource {
func activityIndicatorParent() -> UIViewController
}
class CustomActivityIndicator: UIView {
private var activityIndicatorImageView: UIImageView
var dataSource: CustomActivityIndicatorDataSource? {
didSet {
self.setupView()
}
}
// Singleton
statice let sharedInstance = CustomActivityIndicator()
// MARK:- Initialiser
private init() {
var frame = CGRectMake(0,0,200,200)
self.activityIndicatorImageView = UIImageView(frame: frame)
self.activityIndicatorImageView.image = UIImage(named: "myImage")
super.init(frame: frame)
self.addSubview(self.activityIndicatorImageView)
self.hidden = true
}
internal required init?(coder aDecoder: NSCoder) {
self.activityIndicatorImageView = UIImageView(frame: CGRectMake(0, 0, 200, 200))
self.activityIndicatorImageView = UIImage(named: "myImage")
super.init(coder: aDecoder)
}
// MARK:- Helper methods
private func setupView() {
if self.dataSource != nil {
self.removeFromSuperview()
self.dataSource!.activityIndicatorParent().addSubview(self)
self.center = self.dataSource!.activityIndicatorParent().center
}
}
// MARK:- Animation methods
/**
Set active to true, and starts the animation
*/
func startAnimation() {
// A custom class which does thread handling. But you can use the dispatch methods.
ThreadController.performBlockOnMainQueue{
self.active = true
self.myAnimation
self.hidden = false
}
}
/**
This will set the 'active' boolean to false.
Remeber to remove the view from the superview manually
*/
func stopAnimation() {
ThreadController.performBlockOnMainQueue{
self.active = false
self.hidden = true
}
}
}
Mind that this is not fully tested and may need some tweaks to work 100%, but this is how I basically would handle it.

Related

Rendering NSView in another context

Im looking for a way to have one NSView draw inside another NSView, to make a preview of that view. To be exact Im using VZVirtualMachineView and want to have a preview only in a grid. I would just use VZVirtualMachineView but there is one issue with it - it captures mouse cursor (can hide it) and reacts to clicks and mouse movement over it. And in this case I want to have only the preview with no interaction and no cursor hiding.
So I thought I might try rendering it inside another NSView using vmView.layer?.render(in: context.cgContext), or some other similar mechanism.
The question is how to do this properly? And how to make it refresh with correct time? Should I use something like CADisplayLink? Do I need to add this view first somewhere or can I draw it without adding? Also I would like to not use something like creating image (unless this would be efficient method)
Here is very simple first try, but Im not happy with that code, so
Please suggest some approaches that I should try.
class VMPreview: NSView {
let vmView: VZVirtualMachineView
var timer: Timer?
init(virtualMachine: VZVirtualMachine) {
self.vmView = .init()
self.vmView.virtualMachine = virtualMachine
super.init(frame: .zero)
addSubview(self.vmView)
dr()
}
func dr() {
self.setNeedsDisplay(bounds)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.dr()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var frame: NSRect {
didSet {
self.vmView.frame = bounds.offsetBy(dx: 0, dy: bounds.height)
}
}
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current else { return }
vmView.layer?.render(in: context.cgContext)
self.setNeedsDisplay(bounds)
}
}

How, exactly, do I render Metal on a background thread?

This problem is caused by user interface interactions such as showing the titlebar while in fullsreen. That question's answer provides a solution, but not how to implement that solution.
The solution is to render on a background thread. The issue is, the code provided in Apple's is made to cover a lot of content so most of it will extraneous code, so even if I could understand it, it isn't feasible to use Apple's code. And I can't understand it so it just plain isn't an option. How would I make a simple Swift Metal game use a background thread being as concise as possible?
Take this, for example:
class ViewController: NSViewController {
var MetalView: MTKView {
return view as! MTKView
}
var Device: MTLDevice = MTLCreateSystemDefaultDevice()!
override func viewDidLoad() {
super.viewDidLoad()
MetalView.delegate = self
MetalView.device = Device
MetalView.colorPixelFormat = .bgra8Unorm_srgb
Device = MetalView.device
//setup code
}
}
extension ViewController: MTKViewDelegate {
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
}
func draw(in view: MTKView) {
//drawing code
}
}
That is the start of a basic Metal game. What would that code look like, if it were rendering on a background thread?
To fix that bug when showing the titlebar in Metal, I need to render it on a background thread. Well, how do I render it on a background thread?
I've noticed this answer suggests to manually redraw it 60 times a second. Presumably using a loop that is on a background thread? But that seems... not a clean way to fix it. Is there a cleaner way?
The main trick in getting this to work seems to be setting up the CVDisplayLink. This is awkward in Swift, but doable. After some work I was able to modify the "Game" template in Xcode to use a custom view backed by CAMetalLayer instead of MTKView, and a CVDisplayLink to render in the background, as suggested in the sample code you linked — see below.
Edit Oct 22:
The approach mentioned in this thread seems to work just fine: still using an MTKView, but drawing it manually from the display link callback. Specifically I was able to follow these steps:
Create a new macOS Game project in Xcode.
Modify GameViewController to add a CVDisplayLink, similar to below (see this question for more on using CVDisplayLink from Swift). Start the display link in viewWillAppear and stop it in viewWillDisappear.
Set mtkView.isPaused = true in viewDidLoad to disable automatic rendering, and instead explicitly call mtkView.draw() from the display link callback.
The full content of my modified GameViewController.swift is available here.
I didn't review the Renderer class for thread safety, so I can't be sure no more changes are required, but this should get you up and running.
Older implementation with CAMetalLayer instead of MTKView:
This is just a proof of concept and I can't guarantee it's the best way to do everything. You might find these articles helpful too:
I didn't try this idea, but given how much convenience MTKView generally provides over CAMetalLayer, it might be worth giving it a shot:
https://developer.apple.com/forums/thread/89241?answerId=268384022#268384022
Is drawing to an MTKView or CAMetalLayer required to take place on the main thread? and https://developer.apple.com/documentation/quartzcore/cametallayer/1478157-presentswithtransaction
class MyMetalView: NSView {
var displayLink: CVDisplayLink?
var metalLayer: CAMetalLayer!
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupMetalLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupMetalLayer()
}
override func makeBackingLayer() -> CALayer {
return CAMetalLayer()
}
func setupMetalLayer() {
wantsLayer = true
metalLayer = layer as! CAMetalLayer?
metalLayer.device = MTLCreateSystemDefaultDevice()!
// ...other configuration of the metalLayer...
}
// handle display link callback at 60fps
static let _outputCallback: CVDisplayLinkOutputCallback = { (displayLink, inNow, inOutputTime, flagsIn, flagsOut, context) -> CVReturn in
// convert opaque context pointer back into a reference to our view
let view = Unmanaged<MyMetalView>.fromOpaque(context!).takeUnretainedValue()
/*** render something into view.metalLayer here! ***/
return kCVReturnSuccess
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard CVDisplayLinkCreateWithActiveCGDisplays(&displayLink) == kCVReturnSuccess,
let displayLink = displayLink
else {
fatalError("unable to create display link")
}
// pass a reference to this view as an opaque pointer
guard CVDisplayLinkSetOutputCallback(displayLink, MyMetalView._outputCallback, Unmanaged<MyMetalView>.passUnretained(self).toOpaque()) == kCVReturnSuccess else {
fatalError("unable to configure output callback")
}
guard CVDisplayLinkStart(displayLink) == kCVReturnSuccess else {
fatalError("unable to start display link")
}
}
deinit {
if let displayLink = displayLink {
CVDisplayLinkStop(displayLink)
}
}
}

How to create block screen with circle loader

I am doing an app that does background job that can take some time
I want to show a loader in that time
I want a black screen with a simple loader in the front of it
and show it \ hide it,
when I do actions in the background
I want to do a simple half black square with loader circle
that also blocks presses to the screen
Like in this picture:
How can I achieve that and that ?
First create one UIView which you will put in front of your LogIn view. Then add UIActivityIndicatorView to the created UIView.
let loadingIndicatorView = UIView()
let activityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray)
Now the loadingIndicatorView should have same frame size as your LogIN view. For color you can set your own color with alpha as you want to show LogIn content too. Initially keep it hidden and whenever you want to show it unhide it.
loadingIndicatorView.frame = view.frame
loadingIndicatorView.backgroundColor = .gray
loadingIndicatorView.isHidden = true
Now setup activityIndicatorView, it should be shown at centre,
activityIndicatorView.center = CGPoint(
x: UIScreen.main.bounds.size.width / 2,
y: UIScreen.main.bounds.size.height / 2
)
You can set some color to the indicator,
activityIndicatorView.color = .white
activityIndicatorView.hidesWhenStopped = true
Now add this activityIndicatorView to loadingIndicatorView and loadingIndicatorView to LogIn View.
loadingIndicatorView.addSubview(activityIndicatorView)
view.addSubview(loadingIndicatorView)
Lastly for showing do,
loadingIndicator.startAnimating()
loadingIndicatorView.isHidden = false
And for hiding,
loadingIndicator.stopAnimating()
loadingIndicatorView.isHidden = true
Updated Answer
Since the OP wanted an example code. Hence the updated answer. Hope everyone gets to learn something or the other out of it.
To start with, I created a subclass of UIView and named it PSOverlaySpinner and it looks something like below:
import UIKit
class PSOverlaySpinner: UIView {
//MARK: - Variables
private var isSpinning: Bool = false
private lazy var spinner : UIActivityIndicatorView = {
var spinner = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.white)
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.hidesWhenStopped = true
return spinner
}()
// MARK: - View Lifecycle Functions
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
init() {
super.init(frame: CGRect.zero)
self.translatesAutoresizingMaskIntoConstraints = false
self.backgroundColor = UIColor.init(white: 0.0, alpha: 0.8)
self.isSpinning = false
self.isHidden = true
createSubviews()
}
deinit {
self.removeFromSuperview()
}
func createSubviews() -> Void {
self.addSubview(spinner)
setupAutoLayout()
}
// MARK: - Private Methods
private func setupAutoLayout() {
if #available(iOS 11.0, *) {
spinner.safeAreaLayoutGuide.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor).isActive = true
spinner.safeAreaLayoutGuide.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor).isActive = true
} else {
// Fallback on earlier versions
spinner.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
spinner.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
}
}
// MARK: - Public Methods
public func show() -> Void {
DispatchQueue.main.async {
if !self.spinner.isAnimating {
self.spinner.startAnimating()
}
self.isHidden = false
}
isSpinning = true
}
public func hide() -> Void {
DispatchQueue.main.async {
if self.spinner.isAnimating {
self.spinner.stopAnimating()
}
self.isHidden = true
}
isSpinning = false
}
}
Now move onto the ViewController that you want to add this overlay view to. Since I create my views programmatically, I will show how to do it the same way, but you can easily do it via storyboard or xibs.
Step 1 : Initialize
public lazy var spinnerView : PSOverlaySpinner = {
let loadingView : PSOverlaySpinner = PSOverlaySpinner()
return loadingView
}()
Step 2 : Add as a subview
self.view.addSubview(spinnerView)
Step 3 : Set constraints
spinnerView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
spinnerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
spinnerView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
spinnerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
Step 4 : To show PSOverlaySpinner
spinnerView.show()
Step 5 : To hide PSOverlaySpinner
spinnerView.hide()
That is it!!
If you want you can go ahead and modify the PSOverlaySpinner as per your needs. For example, you might want to add a UILabel below the spinner indicating him of the type of action taking place and so on.
Before
After
Old Answer
If you wish to do it manually then create a UIView with the its frame matching self.view.bounds, with 0.5-0.7 alpha and black background color. Add UIActivityIndicator as its subview constrained to its center. For a spinner specific to the image you will have to use the open sourced spinners made available. A couple of them can be found here. Once done add this view as the topmost subview in self.view.
You need to import this library SVProgressHUD and then set few properties like as follows:
SVProgressHUD.setDefaultStyle(SVProgressHUDStyle.dark)
SVProgressHUD.setBackgroundColor(.clear)
SVProgressHUD.setForegroundColor(.white)
SVProgressHUD.setDefaultMaskType(.black)
SVProgressHUD.show()
//SVProgressHUD.show(withStatus: "Loading something, Loading something,Loading something ...")
This will produce same UI output as needed by you in OP. You can find a running sample at my repository (TestPreLoader)

Using a CALayer in an NSStatusBarButton

I'm interested in drawing custom content in a NSStatusBarButton similar to the built-in battery menu bar app:
I'd like to use a CALayer due to the flexibility of drawing custom content. I've managed to add a layer-backed NSView to the button using this code:
// StatusView
class StatusView: NSView {
required init?(coder: NSCoder) {
super.init(coder: coder)
self.wantsLayer = true
}
override var wantsUpdateLayer: Bool {
return true
}
override func updateLayer() {
if let layer = self.layer {
layer.backgroundColor = NSColor.clear.cgColor
// Code adding text layer...
}
}
}
// MenuController
class StatusMenuController: NSObject {
override func awakeFromNib() {
if let button = statusItem.button {
layerView.frame = NSRect(x: 0.0, y: 0.0, width: len / 2, height: button.bounds.height)
button.addSubview(layerView)
}
}
}
Basically I'm adding a CALayer to the button of the NSStatusBarButton, which looks fine normally, but seems to have an opaque background when you click the menu item:
I've set the background color of the CALayer to transparent and the isOpaque property of the layer's view is false. Is there a way to get the view to actually blend with the background, similar to the battery menu bar app?
Thanks!

Why does CABasicAnimation try to initialize another instance of my custom CALayer?

I get this error:
fatal error: use of unimplemented initializer 'init(layer:)' for class 'MyProject.AccordionLayer'
using the following code. In my view controller:
override func viewDidLoad() {
let view = self.view as! AccordionView!
view.launchInitializationAnimations()
}
In my view:
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
for var i = 0; i < self.accordions.count; ++i {
var layer = AccordionLayer(accordion: self.accordions[i])
layer.accordion = self.accordions[i]
layer.frame = CGRectMake(0, CGFloat(i) * self.getDefaultHeight(), self.frame.width, self.getDefaultHeight())
self.layer.addSublayer(layer)
layer.setNeedsDisplay()
layers.append(layer)
}
}
func launchInitializationAnimations() {
for layer in self.layer.sublayers {
var animation = CABasicAnimation(keyPath: "topX")
animation.duration = 2.0
animation.fromValue = CGFloat(0.0)
animation.toValue = CGFloat(200.0)
layer.addAnimation(animation, forKey: "animateTopX")
}
}
And in my subclassed CALayer
var topX : Int
init(accordion: (color: CGColorRef!, header: String, subtitle: String, image: UIImage?)!) {
// Some initializations
// ...
super.init()
}
I also implement needsDisplayForKey, drawInContext.
I have seen 2-3 other questions with the same error message but I can't really figure out how it is related to my specific case on my own.
Why is CABasicAnimation trying to instantiate a new (my custom) CALayer?
I just ran into the same issue, and I got it working after adding the init(layer:) to my custom CALayer:
override init(layer: Any) {
super.init(layer: layer)
}
I hope it helps whoever get here.
EDIT As Ben Morrow comments, in your override you also have to copy across the property values from your custom class. For an example, see his https://stackoverflow.com/a/38468678/341994.
Why is CABasicAnimation trying to instantiate a new (my custom) CALayer?
Because animation involves making a copy of your layer to act as the presentation layer.