I'm the author of a Hearthstone tracker, and I have to move several NSWindow over Hearthstone window.
I get the frame of Hearthstone using CGWindowListCopyWindowInfo.
Then, I have to move my windows at some positions relative to Hearthstone.
The red arrows are over opponent cards, green arrow is over turn button and blue arrows are at the left and right of the window.
My actual screen setup is the following :
which gives me the following frames
// screen 1 : {x 0 y 0 w 1.440 h 900}
// screen 2 : {x 1.440 y -180 w 1.920 h 1.080}
To place the opponent tracker (the left frame) at the right position, which is the most simple case, I use {x 0 y somepadding w 185 h hearthstoneHeight - somepadding} and get the correct frame with this
func relativeFrame(frame: NSRect) -> NSRect {
var relative = frame
relative.origin.x = NSMinX(hearthstoneFrame) + NSMinX(frame)
relative.origin.y = NSMinY(hearthstoneFrame) + NSMinY(frame)
return relative
}
The right tracker is placed using {x hearthstoneWidth - trackerWidth, ...}
For other overlays, I used my current (Hearthstone) resolution to place them and them calculate them using a simple math
x = x / 1404.0 * NSWidth(hearthstoneFrame)
y = y / 840.0 * NSHeight(hearthstoneFrame)
This works pretty well. Except if I use my second screen. In this case, the frames seems to be correct, but the position of the window is not good.
Here is a screenshot of a debug window with {x 0 y 0 w hearthstoneWidth h hearthsoneHeight }. If I compare the frames of Hearthstone and my overlay, they are identical.
The complete function is the following (I'm in a "static class", I only show revelant code). I guess I'm missing something in the calculation but I can't find what.
class frameRelative {
static var hearthstoneFrame = NSZeroRect
static func findHearthstoneFrame() {
let options = CGWindowListOption(arrayLiteral: .ExcludeDesktopElements)
let windowListInfo = CGWindowListCopyWindowInfo(options, CGWindowID(0))
if let info = (windowListInfo as NSArray? as? [[String: AnyObject]])?
.filter({
!$0.filter({ $0.0 == "kCGWindowName" && $0.1 as? String == "Hearthstone" }).isEmpty
})
.first {
var rect = NSRect()
let bounds = info["kCGWindowBounds"] as! CFDictionary
CGRectMakeWithDictionaryRepresentation(bounds, &rect)
rect.size.height -= titleBarHeight // remove the 22px from the title
hearthstoneFrame = rect
}
}
static func frameRelative(frame: NSRect, _ isRelative: Bool = false) -> NSRect {
var relative = frame
var pointX = NSMinX(relative)
var pointY = NSMinY(relative)
if isRelative {
pointX = pointX / 1404.0 * NSWidth(hearthstoneFrame)
pointY = pointY / 840.0 * NSHeight(hearthstoneFrame)
}
let x: CGFloat = NSMinX(hearthstoneFrame) + pointX
let y = NSMinY(hearthstoneFrame) + pointY
relative.origin = NSMakePoint(x, y)
return relative
}
}
// somewhere here
let frame = NSMakeRect(0, 0, hearthstoneWidth, hearthstoneHeight)
let relativeFrame = SizeHelper.frameRelative(frame)
myWindow.setFrame(relativeFrame, display: true)
Any help will be appreciate :)
I eventually solved this issue so I decided to post the answer to close this thread... and maybe if someone face the same issue one day.
The solution was to substract the max y from the first screen with the max y of the Hearthstone window.
The final code of findHearthstoneFrame is
static func findHearthstoneFrame() {
let options = CGWindowListOption(arrayLiteral: .ExcludeDesktopElements)
let windowListInfo = CGWindowListCopyWindowInfo(options, CGWindowID(0))
if let info = (windowListInfo as NSArray? as? [[String: AnyObject]])?.filter({
!$0.filter({ $0.0 == "kCGWindowName"
&& $0.1 as? String == "Hearthstone" }).isEmpty
}).first {
if let id = info["kCGWindowNumber"] as? Int {
self.windowId = CGWindowID(id)
}
var rect = NSRect()
let bounds = info["kCGWindowBounds"] as! CFDictionary
CGRectMakeWithDictionaryRepresentation(bounds, &rect)
if let screen = NSScreen.screens()?.first {
rect.origin.y = NSMaxY(screen.frame) - NSMaxY(rect)
}
self._frame = rect
}
}
And the frameRelative is
static let BaseWidth: CGFloat = 1440.0
static let BaseHeight: CGFloat = 922.0
var scaleX: CGFloat {
return NSWidth(_frame) / SizeHelper.BaseWidth
}
var scaleY: CGFloat {
// 22 is the height of the title bar
return (NSHeight(_frame) - 22) / SizeHelper.BaseHeight
}
func frameRelative(frame: NSRect, relative: Bool = true) -> NSRect {
var pointX = NSMinX(frame)
var pointY = NSMinY(frame)
let width = NSWidth(frame)
let height = NSHeight(frame)
if relative {
pointX = pointX * scaleX
pointY = pointY * scaleY
}
let x: CGFloat = NSMinX(self.frame) + pointX
let y: CGFloat = NSMinY(self.frame) + pointY
let relativeFrame = NSRect(x: x, y: y, width: width, height: height)
return relativeFrame
}
Related
I'm trying to draw a logo tiled over an image at 45 degrees.But I always get a spacing on the left side.
var y_offset: CGFloat = logo.size.width * sin(45 * (CGFloat.pi / 180.0))
// the sin of the angle may return zero or negative value,
// it won't work with this formula
if y_offset >= 0 {
var x: CGFloat = 0
while x < size.width {
var y: CGFloat = 0
while y < size.height {
// move to this position
context.saveGState()
context.translateBy(x: x, y: y)
// draw text rotated around its center
context.rotate(by: ((CGFloat(-45) * CGFloat.pi ) / 180))
logo.draw(at:NSPoint(x:x,y:y), from: .zero, operation: .sourceOver, fraction: CGFloat(logotransparency))
// reset
context.restoreGState()
y = y + CGFloat(y_offset)
}
x = x + logo.size.width
}}
}
This is the result what I get.
As you can see there are some spacing present on the left side.I cannot figure out what I'm doing wrong.I have tried setting y to size.height and decrementing it by y_offset in the loop.But I get the same result.
Update:
var dirtyRect:NSRect=NSMakeRect(0, 0, size.width, size.height)
let deg45 = CGFloat.pi / 4
if let ciImage = logo.ciImage {
let ciTiled = ciImage.tiled(at: deg45).cropped(to: dirtyRect)
let color = NSColor.init(patternImage: NSImage.fromCIImage(ciTiled))
color.setFill()
context.fill(dirtyRect)
}
Updated answer
If you need more control over the appearance you can go with manually drawing the overlays. See below code for a fixed version of your original code with two options for spacing.
In production, you would of course want to avoid using ! and move the image loading out of the draw function (even though NSImage(named:) uses a cache).
override func draw(_ dirtyRect: NSRect) {
let bgImage = NSImage(named: "landscape")!
bgImage.draw(in: dirtyRect)
let deg45 = CGFloat.pi / 4
let logo = NSImage(named: "TextTile")!
let context = NSGraphicsContext.current!.cgContext
let h = logo.size.height // (sin(deg45) * logo.size.height) + (cos(deg45) * logo.size.height)
let w = logo.size.width // (sin(deg45) * logo.size.width ) + (cos(deg45) * logo.size.width )
var x: CGFloat = -w
while x < dirtyRect.width + w {
var y: CGFloat = -h
while y < dirtyRect.height + h {
context.saveGState()
context.translateBy(x: x, y: y)
context.rotate(by: deg45)
logo.draw(at:NSPoint(x:0,y:0),
from: .zero,
operation: .sourceOver,
fraction: 1)
context.restoreGState()
y = y + h
}
x = x + w
}
super.draw(dirtyRect)
}
Original answer
You can set a backgroundColor with a patternImage to for the effect of drawing image tiles in a rect.
To tilt the image by some angle, use CIImage's CIAffineTile option with some transformation.
Here is some example code:
import Cocoa
import CoreImage
class ViewController: NSViewController {
override func loadView() {
let size = CGSize(width: 500, height: 500)
let view = TiledView(frame: CGRect(origin: CGPointZero, size: size))
self.view = view
}
}
class TiledView: NSView {
override func draw(_ dirtyRect: NSRect) {
let bgImage = NSImage(named: "landscape")!
bgImage.draw(in: dirtyRect)
let deg45 = CGFloat.pi / 4
if let ciImage = NSImage(named: "TextTile")?.ciImage() {
let ciTiled = ciImage.tiled(at: deg45).cropped(to: dirtyRect)
let color = NSColor.init(patternImage: NSImage.fromCIImage(ciTiled))
color.setFill()
dirtyRect.fill()
}
super.draw(dirtyRect)
}
}
extension NSImage {
// source: https://rethunk.medium.com/convert-between-nsimage-and-ciimage-in-swift-d6c6180ef026
func ciImage() -> CIImage? {
guard let data = self.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: data) else {
return nil
}
let ci = CIImage(bitmapImageRep: bitmap)
return ci
}
static func fromCIImage(_ ciImage: CIImage) -> NSImage {
let rep = NSCIImageRep(ciImage: ciImage)
let nsImage = NSImage(size: rep.size)
nsImage.addRepresentation(rep)
return nsImage
}
}
extension CIImage {
func tiled(at angle: CGFloat) -> CIImage {
// try different transforms here
let transform = CGAffineTransform(rotationAngle: angle)
return self.applyingFilter("CIAffineTile", parameters: [kCIInputTransformKey: transform])
}
}
The result looks like this:
I'm trying to prevent the mouse cursor from leaving a specific area of the screen. I can't find a native method to do this, so I'm trying to do it manually.
So far I have this:
NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged], handler: {(event: NSEvent) in
let x = event.locationInWindow.flipped.x;
let y = event.locationInWindow.flipped.y;
if (x <= 100) {
CGWarpMouseCursorPosition(CGPoint(x: 100, y: y))
}
})
// elsewhere to flip y coordinates
extension NSPoint {
var flipped: NSPoint {
let screenFrame = (NSScreen.main?.frame)!
let screenY = screenFrame.size.height - self.y
return NSPoint(x: self.x, y: screenY)
}
}
This stops the cursor from going off the X axis. Great. But it also stops the cursor from sliding along the y axis at X=100.
So I tried to add the delta:
NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged], handler: {(event: NSEvent) in
let x = event.locationInWindow.flipped.x;
let y = event.locationInWindow.flipped.y;
let deltaY = event.deltaY;
if (x <= 100) {
CGWarpMouseCursorPosition(CGPoint(x: 100, y: y + deltaY))
}
})
Now it does slide along the Y axis. But the acceleration is way off, it's too fast. What I don't get is that if I try to do y - deltaY it slides like I expect, but reversed:
NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged], handler: {(event: NSEvent) in
let x = event.locationInWindow.flipped.x;
let y = event.locationInWindow.flipped.y;
let deltaY = event.deltaY;
if (x <= 100) {
CGWarpMouseCursorPosition(CGPoint(x: 100, y: y - deltaY))
}
})
Now the cursor is sliding along the Y axis at X=100 with proper acceleration (like sliding the cursor against the edge of the screen), but it's reversed. Moving the mouse up, moves the cursor down.
How do I get proper smooth sliding of the cursor, in the proper direction, at the edge of my custom area?
Or is there a better way to achieve what I'm trying to do?
I figured it out. I need to subtract the previous deltas.
So now I have this instead:
var oldDeltaX: CGFloat = 0;
var oldDeltaY: CGFloat = 0;
NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged], handler: {(event: NSEvent) in
let deltaX = event.deltaX - oldDeltaX;
let deltaY = event.deltaY - oldDeltaY;
let x = event.locationInWindow.flipped.x;
let y = event.locationInWindow.flipped.y;
let window = (NSScreen.main?.frame.size)!;
let width = CGFloat(1920);
let height = CGFloat(1080);
let widthCut = (window.width - width) / 2;
let heightCut = (window.height - height) / 2;
let xPoint = clamp(x + deltaX, minValue: widthCut, maxValue: window.width - widthCut);
let yPoint = clamp(y + deltaY, minValue: heightCut, maxValue: window.height - heightCut);
oldDeltaX = xPoint - x;
oldDeltaY = yPoint - y;
CGWarpMouseCursorPosition(CGPoint(x: xPoint, y: yPoint));
});
public func clamp<T>(_ value: T, minValue: T, maxValue: T) -> T where T : Comparable {
return min(max(value, minValue), maxValue)
}
extension NSPoint {
var flipped: NSPoint {
let screenFrame = (NSScreen.main?.frame)!
let screenY = screenFrame.size.height - self.y
return NSPoint(x: self.x, y: screenY)
}
}
This will restrict the mouse in a 1920x1080 square of the display.
I found Godot's source code to be good resource: https://github.com/godotengine/godot/blob/51a00c2855009ce4cd6475c09209ebd22641f448/platform/osx/display_server_osx.mm#L1087
Is this the best or most perfomant way to do it? I don't know, but it works.
I'm trying to figure out how setColor works. I have the following code:
lazy var imageView:NSImageView = {
let imageView = NSImageView(frame: view.frame)
return imageView
}()
override func viewDidLoad() {
super.viewDidLoad()
createColorProjection()
view.wantsLayer = true
view.addSubview(imageView)
view.needsDisplay = true
}
func createColorProjection() {
var bitmap = NSBitmapImageRep(cgImage: cgImage!)
var x = 0
while x < bitmap.pixelsWide {
var y = 0
while y < bitmap.pixelsHigh {
//pixels[Point(x: x, y: y)] = (getColor(x: x, y: y, bitmap: bitmap))
bitmap.setColor(NSColor(cgColor: .black)!, atX: x, y: y)
y += 1
}
x += 1
}
let image = createImage(bitmap: bitmap)
imageView.image = image
imageView.needsDisplay = true
}
func createImage(bitmap:NSBitmapImageRep) -> NSImage {
let image = bitmap.cgImage
return NSImage(cgImage: image! , size: CGSize(width: image!.width, height: image!.height))
}
The intention of the code is to change a photo (a rainbow) to be entirely black (I'm just testing with black right now to make sure I understand how it works). However, when I run the program, the unchanged picture of the rainbow is shown, not a black photo.
I am getting these errors:
Unrecognized colorspace number -1 and Unknown number of components for colorspace model -1.
Thanks.
First, you're right: setColor has been broken at least since Catalina. Apple hasn't fixed it probably because it's so slow and inefficient, and nobody ever used it.
Second, docs say NSBitmapImageRep(cgImage: CGImage) produces a read-only bitmap so your code wouldn't have worked even if setColor worked.
As Alexander says, making your own CIFilter is the best way to change a photo's pixels to different colors. Writing and implementing the OpenGL isn't easy, but it's the best.
If you were to add an extension to NSBitmapImageRep like this:
extension NSBitmapImageRep {
func setColorNew(_ color: NSColor, atX x: Int, y: Int) {
guard let data = bitmapData else { return }
let ptr = data + bytesPerRow * y + samplesPerPixel * x
ptr[0] = UInt8(color.redComponent * 255.1)
ptr[1] = UInt8(color.greenComponent * 255.1)
ptr[2] = UInt8(color.blueComponent * 255.1)
if samplesPerPixel > 3 {
ptr[3] = UInt8(color.alphaComponent * 255.1)
}
}
}
Then simply changing an image's pixels could be done like this:
func changePixels(image: NSImage, newColor: NSColor) -> NSImage {
guard let imgData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: imgData),
let color = newColor.usingColorSpace(.deviceRGB)
else { return image }
var y = 0
while y < bitmap.pixelsHigh {
var x = 0
while x < bitmap.pixelsWide {
bitmap.setColorNew(color, atX: x, y: y)
x += 1
}
y += 1
}
let newImage = NSImage(size: image.size)
newImage.addRepresentation(bitmap)
return newImage
}
I am building a messanger view in iOs (Swift) app with help of UICollectionView inside a UIViewController. I am taking inspiration from MessageKit and I was able to set everything properly with simple dynamic cell height. When I hide the keyboard and the collection view bottom inset is reduced while the collection view is scrolled to bottom, it logically drags cells in to the view from top (scrolls down). I am not sure if it is somehow in conflict with keyboard hiding animation, but if this cause the collection view scroll to much and therefore display cells that have not been in the view, they appear not as scrolled in, but with some strange layout animation. It happens only while hiding keyboard && collectionView is at the bottom. Please check the gif:
link to gif
Building everything on UITableView did work, but I aim for collection view due to future features. I tried to use even a fixed cell height in a CollectionViewFlowDelegate, but it has the same effect as dynamically calculated heights.
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
I am setting the UICollectionView inset the same way as in the MessageKit:
private func requiredScrollViewBottomInset(forKeyboardFrame keyboardFrame: CGRect) -> CGFloat {
let intersection = chatCollectionView.frame.intersection(keyboardFrame)
if intersection.isNull || (chatCollectionView.frame.maxY - intersection.maxY) > 0.001 {
messagesCollectionView.frame.maxY when dealing with undocked keyboards.
return max(0, additionalBottomInset - automaticallyAddedBottomInset)
} else {
return max(0, intersection.height + additionalBottomInset - automaticallyAddedBottomInset)
}
}
#objc private func handleKeyboardDidChangeState(_ notification: Notification) {
guard !isMessagesControllerBeingDismissed else { return }
guard let keyboardStartFrameInScreenCoords = notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? CGRect else { return }
guard !keyboardStartFrameInScreenCoords.isEmpty || UIDevice.current.userInterfaceIdiom != .pad else {
// WORKAROUND for what seems to be a bug in iPad's keyboard handling in iOS 11: we receive an extra spurious frame change
// notification when undocking the keyboard, with a zero starting frame and an incorrect end frame. The workaround is to
// ignore this notification.
return
}
guard let keyboardEndFrameInScreenCoords = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
let keyboardEndFrame = view.convert(keyboardEndFrameInScreenCoords, from: view.window)
let newBottomInset = requiredScrollViewBottomInset(forKeyboardFrame: keyboardEndFrame)
let differenceOfBottomInset = newBottomInset - messageCollectionViewBottomInset
if maintainPositionOnKeyboardFrameChanged && differenceOfBottomInset >/*!=*/ 0 {
let contentOffset = CGPoint(x: chatCollectionView.contentOffset.x, y: chatCollectionView.contentOffset.y + differenceOfBottomInset)
chatCollectionView.setContentOffset(contentOffset, animated: false)
}
messageCollectionViewBottomInset = newBottomInset
}
internal func requiredInitialScrollViewBottomInset() -> CGFloat {
print("accessory view for initial bottom inset: \(inputAccessoryView)")
guard let inputAccessoryView = inputAccessoryView else { return 0 }
return max(0, inputAccessoryView.frame.height + additionalBottomInset - automaticallyAddedBottomInset)
}
As I could not find any related topic regarding this scrolling upon keyboard hiding, I am not sure if this is reusableCell issue or animation conflict?
EDIT
So the partial solution is to invalidate layout only if the width change, this will prevent keyboard invalidating it while hiding:
open override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return collectionView?.bounds.width != newBounds.width
}
But it also prevents sticky headers to get invalidated and therefore resign being sticky. I went deeper into invalidationContext as this looked as potential full solution, although I get the same behaviour.
open override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
invalidateLayout(with: invalidationContext(forBoundsChange: newBounds))
return collectionView?.bounds.width != newBounds.width
}
open override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds)
guard let flowLayoutContext = context as? UICollectionViewFlowLayoutInvalidationContext else { return context }
let indexes: [IndexPath] = (collectionView?.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader))!
print(indexes)
flowLayoutContext.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader, at: indexes)
print(context.invalidatedSupplementaryIndexPaths)
return flowLayoutContext
}
Print statements clearly state that only headers are invalidated, and for the rest I am returning false. But it behaves exactly the same as in the gif (see the link - unfortunately I have reputation not high enough yet to add it directly here). Thank you for any comments!
After long investigation I noticed that the same phenomen happens also at the bottom in particular cases. I am not sure it it is bug or custom layout is necessary, but with the simple flow layout I solved the issue by setting collectionView contraints beyound the display edge.
This force to call displaying cell earlier and gives some time to lay cell approprietely even hide keyboard animation is used. Of coarse collectionView top and bottom contentInset has to be set in viewDidLoad and handle any contentInset change during runtime accordingly
Hope it help!
EDIT:
I ended up using CADisplaLink for scrolling of the collectionView when the keyboard shows and setting the bottom inset without animation. This case the inset to change exactly in the moment, when scroll function is scrolling the collection, so there is no jump and each frame has correct layout. For the keyboard hide it is quite simple - setting bottom inset in keyboardWillHide cause the collection to adjust offset upon releasing the finger also outside animation block and therefore is the layout also correct.
Following code is inspired by multiple answers here and by MessakeKit on GitHub
let scrollController = ScrollController()
override func viewDidLoad() {
super.viewDidLoad()
scrollController.scrollView = self.chatCollectionView
scrollController.decelerationAllowed = true
scrollController.keepUserInteractionDuringScroll = true
}
#objc private func keyboardWillShowOrHide(_ notification: Notification) {
guard !ignoreKeyboardNotification, !insertingMessage, !isViewControllerBeingDismissed, viewAlreadyAppeared else { return }
guard let keyboardEndFrameInScreenCoords = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
let keyboardEndFrame = view.convert(keyboardEndFrameInScreenCoords, from: view.window)
let newBottomInset = requiredScrollViewBottomInset(forKeyboardFrame: keyboardEndFrame)
let deltaBottomInset = newBottomInset - messageCollectionViewBottomInset
let differenceOfBottomInset = min(deltaBottomInset, collectionViewContentHightIntersectSafeArea())
ignoreScrollButtonHide = true
let scrollButtonOriginY = keyboardEndFrameInScreenCoords.origin.y - (self.scrollToBottomButtonMargin + self.scrollButton.frame.size.height)
UIView.performWithoutAnimation {
let contentOffset = chatCollectionView.contentOffset
self.messageCollectionViewBottomInset = newBottomInset
self.chatCollectionView.contentOffset = contentOffset
}
if !chatCollectionView.isTracking {
if differenceOfBottomInset != 0 {
let beginOffsetY = self.scrollController.endContentOffset == CGPoint.zero ? self.chatCollectionView.contentOffset.y : self.scrollController.endContentOffset.y
let negativeScroll = differenceOfBottomInset < 0//if difference is negative, it should not scroll pass the topScrollOffset
let setContentOffsetY = negativeScroll ? max(beginOffsetY + differenceOfBottomInset, topScrollOffset()) : beginOffsetY + differenceOfBottomInset
let contentOffset = CGPoint(x: 0.0, y: setContentOffsetY)
self.scrollController.setContentOffset(contentOffset, with: .easeOut, duration: self.animationsDuration)
}
}
UIView.animate(withDuration: 0, delay: 0, options: [.curveEaseOut, .allowAnimatedContent, .overrideInheritedOptions, .beginFromCurrentState], animations: {
self.chatCollectionView.verticalScrollIndicatorInsets.bottom = newBottomInset
self.scrollButton.frame.origin.y = scrollButtonOriginY
self.adjustLangSelectionViews()
}, completion: { (complete) in
self.ignoreScrollButtonHide = false
if self.chatCollectionView.isDecelerating == false { //do not hide if still scrolling
self.pinnedHeaderIsHidden(hidden: true)
}
})
}
I unfortunately did not saved, where I got the scrollController code for the CADisplayLink. If author could contact me, Iĺl be more than happy to put the link to the source here.
class ScrollController {
//CADisplayLink
private let scrollAnimationDuration: TimeInterval = 0.25
private let scrollApproximationTolerance: Double = 0.00000001
private let scrollMaximumSteps: Int = 10
var displayLink: CADisplayLink? // Display link used to trigger event to scroll the view
private var timingFunction: CAMediaTimingFunction? // Timing function of an scroll animation
private var duration: CFTimeInterval = 0 // Duration of an scroll animation
private var animationStarted = false // States whether the animation has started
private var beginTime: CFTimeInterval = 0 // Time at the begining of an animation
private var beginContentOffset = CGPoint.zero // The content offset at the begining of an animation
private var deltaContentOffset = CGPoint.zero // The delta between the scroll contentOffsets
var endContentOffset = CGPoint.zero // The delta between the scroll contentOffsets
var scrollView: UIScrollView!
var keepUserInteractionDuringScroll = false
var delegateClass: UIScrollViewDelegate?
var decelerationAllowed: Bool = false
func setContentOffset(_ contentOffset: CGPoint, with timingFunction: CAMediaTimingFunction?, duration: CFTimeInterval, deceleratingAllowed: Bool? = false) {
self.duration = duration
self.timingFunction = timingFunction
deltaContentOffset = CGPointMinus(contentOffset, scrollView.contentOffset)
endContentOffset = contentOffset
decelerationAllowed = deceleratingAllowed!
if displayLink == nil {
displayLink = CADisplayLink(target: self, selector: #selector(updateContentOffset(_:)))
displayLink?.add(to: .main, forMode: .common)
scrollView.isUserInteractionEnabled = keepUserInteractionDuringScroll
} else {
beginTime = 0.0
}
}
#objc func updateContentOffset(_ displayLink: CADisplayLink?) {
if beginTime == 0.0 {
beginTime = self.displayLink?.timestamp ?? 0
beginContentOffset = scrollView.contentOffset
} else {
let deltaTime = (displayLink?.timestamp ?? 0) - beginTime
// Ratio of duration that went by
let progress = CGFloat(deltaTime / duration)
if progress < 1.0 {
// Ratio adjusted by timing function
let adjustedProgress = CGFloat(timingFunctionValue(timingFunction, Double(progress)))
if 1 - adjustedProgress < 0.001 {
stopDisplayLink()
} else {
updateProgress(adjustedProgress)
}
} else {
stopDisplayLink()
}
}
}
func updateProgress(_ progress: CGFloat) {
let currentDeltaContentOffset = CGPointScalarMult(progress, deltaContentOffset)
scrollView.contentOffset = CGPointAdd(beginContentOffset, currentDeltaContentOffset)
}
func stopDisplayLink() {
displayLink?.isPaused = true
beginTime = 0.0
if !decelerationAllowed {
scrollView.setContentOffset(endContentOffset, animated: false)
} else {
scrollView.contentOffset = CGPointAdd(beginContentOffset, deltaContentOffset)
}
endContentOffset = CGPoint.zero
displayLink?.invalidate()
displayLink = nil
scrollView.isUserInteractionEnabled = true
if delegateClass != nil {
delegateClass!.scrollViewDidEndScrollingAnimation?(scrollView)
}
}
func CGPointScalarMult(_ s: CGFloat, _ p: CGPoint) -> CGPoint {
return CGPoint(x: s * p.x, y: s * p.y)
}
func CGPointAdd(_ p: CGPoint, _ q: CGPoint) -> CGPoint {
return CGPoint(x: p.x + q.x, y: p.y + q.y)
}
func CGPointMinus(_ p: CGPoint, _ q: CGPoint) -> CGPoint {
return CGPoint(x: p.x - q.x, y: p.y - q.y)
}
func cubicFunctionValue(_ a: Double, _ b: Double, _ c: Double, _ d: Double, _ x: Double) -> Double {
return (a*x*x*x)+(b*x*x)+(c*x)+d//Double(d as? c as? b as? a ?? 0.0)
}
func cubicDerivativeValue(_ a: Double, _ b: Double, _ c: Double, _ d: Double, _ x: Double) -> Double {
/// Derivation of the cubic (a*x*x*x)+(b*x*x)+(c*x)+d
return (3 * a * x * x) + (2 * b * x) + c
}
func rootOfCubic(_ a: Double, _ b: Double, _ c: Double, _ d: Double, _ startPoint: Double) -> Double {
// We use 0 as start point as the root will be in the interval [0,1]
var x = startPoint
var lastX: Double = 1
// Approximate a root by using the Newton-Raphson method
var y = 0
while y <= scrollMaximumSteps && fabs(lastX - x/*Float(lastX - x)*/) > scrollApproximationTolerance {
lastX = x
x = x - (cubicFunctionValue(a, b, c, d, x) / cubicDerivativeValue(a, b, c, d, x))
y += 1
}
return x
}
func timingFunctionValue(_ function: CAMediaTimingFunction?, _ x: Double) -> Double {
var a = [Float](repeating: 0.0, count: 2)
var b = [Float](repeating: 0.0, count: 2)
var c = [Float](repeating: 0.0, count: 2)
var d = [Float](repeating: 0.0, count: 2)
function?.getControlPoint(at: 0, values: &a)
function?.getControlPoint(at: 1, values: &b)
function?.getControlPoint(at: 2, values: &c)
function?.getControlPoint(at: 3, values: &d)
// Look for t value that corresponds to provided x
let t = rootOfCubic(Double(-a[0] + 3 * b[0] - 3 * c[0] + d[0]), Double(3 * a[0] - 6 * b[0] + 3 * c[0]), Double(-3 * a[0] + 3 * b[0]), Double(a[0]) - x, x)
// Return corresponding y value
let y = cubicFunctionValue(Double(-a[1] + 3 * b[1] - 3 * c[1] + d[1]), Double(3 * a[1] - 6 * b[1] + 3 * c[1]), Double(-3 * a[1] + 3 * b[1]), Double(a[1]), t)
return y
}
Hope it helps!
How can i detect if an ARAnchor is currently visible in the camera, i need to test when the camera view changes.
I want to put arrows on the edge of the screen that point in the direction of the anchor when not on screen. I need to know if the node sits to the left or right of the frustum.
I am now doing this but it says pin is visible when it is not and the X values seem not right? Maybe the renderer frustum does not match the screen camera?
var deltaTime = TimeInterval()
public func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
deltaTime = time - lastUpdateTime
if deltaTime>1{
if let annotation = annotationsByNode.first {
let node = annotation.key.childNodes[0]
if !renderer.isNode(node, insideFrustumOf: renderer.pointOfView!)
{
print("Pin is not visible");
}else {
print("Pin is visible");
}
let pnt = renderer.projectPoint(node.position)
print("pos ", pnt.x, " ", renderer.pointOfView!.position)
}
lastUpdateTime = time
}
}
Update: The code works to show if node is visible or not, how can i tell which direction left or right a node is in relation to the camera frustum?
update2! as suggested answer from Bhanu Birani
let screenWidth = UIScreen.main.bounds.width
let screenHeight = UIScreen.main.bounds.height
let leftPoint = CGPoint(x: 0, y: screenHeight/2)
let rightPoint = CGPoint(x: screenWidth,y: screenHeight/2)
let leftWorldPos = renderer.unprojectPoint(SCNVector3(leftPoint.x,leftPoint.y,0))
let rightWorldPos = renderer.unprojectPoint(SCNVector3(rightPoint.x,rightPoint.y,0))
let distanceLeft = node.position - leftWorldPos
let distanceRight = node.position - rightWorldPos
let dir = (isVisible) ? "visible" : ( (distanceLeft.x<distanceRight.x) ? "left" : "right")
I got it working finally which uses the idea from Bhanu Birani of the left and right of the screen but i get the world position differently, unProjectPoint and also get a scalar value of distance which i compare to get the left/right direction. Maybe there is a better way of doing it but it worked for me
public func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
deltaTime = time - lastUpdateTime
if deltaTime>0.25{
if let annotation = annotationsByNode.first {
guard let pointOfView = renderer.pointOfView else {return}
let node = annotation.key.childNodes[0]
let isVisible = renderer.isNode(node, insideFrustumOf: pointOfView)
let screenWidth = UIScreen.main.bounds.width
let screenHeight = UIScreen.main.bounds.height
let leftPoint = CGPoint(x: 0, y: screenHeight/2)
let rightPoint = CGPoint(x: screenWidth,y: screenHeight/2)
let leftWorldPos = renderer.unprojectPoint(SCNVector3(leftPoint.x, leftPoint.y,0))
let rightWorldPos = renderer.unprojectPoint(SCNVector3(rightPoint.x, rightPoint.y,0))
let distanceLeft = node.worldPosition.distance(vector: leftWorldPos)
let distanceRight = node.worldPosition.distance(vector: rightWorldPos)
//let pnt = renderer.projectPoint(node.worldPosition)
//guard let pnt = renderer.pointOfView!.convertPosition(node.position, to: nil) else {return}
let dir = (isVisible) ? "visible" : ( (distanceLeft<distanceRight) ? "left" : "right")
print("dir" , dir, " ", leftWorldPos , " ", rightWorldPos)
lastDir=dir
delegate?.nodePosition?(node:node, pos: dir)
}else {
delegate?.nodePosition?(node:nil, pos: lastDir )
}
lastUpdateTime = time
}
extension SCNVector3
{
/**
* Returns the length (magnitude) of the vector described by the SCNVector3
*/
func length() -> Float {
return sqrtf(x*x + y*y + z*z)
}
/**
* Calculates the distance between two SCNVector3. Pythagoras!
*/
func distance(vector: SCNVector3) -> Float {
return (self - vector).length()
}
}
Project the ray from the from the following screen positions:
leftPoint = CGPoint(0, screenHeight/2) (centre left of the screen)
rightPoint = CGPoint(screenWidth, screenHeight/2) (centre right of the screen)
Convert CGPoint to world position:
leftWorldPos = convertCGPointToWorldPosition(leftPoint)
rightWorldPos = convertCGPointToWorldPosition(rightPoint)
Calculate the distance of node from both world position:
distanceLeft = node.position - leftWorldPos
distanceRight = node.position - rightWorldPos
Compare distance to find the shortest distance to the node. Use the shortest distance vector to position direction arrow for object.
Here is the code from tsukimi to check if the object is in right side of screen or on left side:
public func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
deltaTime = time - lastUpdateTime
if deltaTime>0.25{
if let annotation = annotationsByNode.first {
guard let pointOfView = renderer.pointOfView else {return}
let node = annotation.key.childNodes[0]
let isVisible = renderer.isNode(node, insideFrustumOf: pointOfView)
let screenWidth = UIScreen.main.bounds.width
let screenHeight = UIScreen.main.bounds.height
let leftPoint = CGPoint(x: 0, y: screenHeight/2)
let rightPoint = CGPoint(x: screenWidth,y: screenHeight/2)
let leftWorldPos = renderer.unprojectPoint(SCNVector3(leftPoint.x, leftPoint.y,0))
let rightWorldPos = renderer.unprojectPoint(SCNVector3(rightPoint.x, rightPoint.y,0))
let distanceLeft = node.worldPosition.distance(vector: leftWorldPos)
let distanceRight = node.worldPosition.distance(vector: rightWorldPos)
//let pnt = renderer.projectPoint(node.worldPosition)
//guard let pnt = renderer.pointOfView!.convertPosition(node.position, to: nil) else {return}
let dir = (isVisible) ? "visible" : ( (distanceLeft<distanceRight) ? "left" : "right")
print("dir" , dir, " ", leftWorldPos , " ", rightWorldPos)
lastDir=dir
delegate?.nodePosition?(node:node, pos: dir)
}else {
delegate?.nodePosition?(node:nil, pos: lastDir )
}
lastUpdateTime = time
}
Following is the class to help performing operations on vector
extension SCNVector3 {
init(_ vec: vector_float3) {
self.x = vec.x
self.y = vec.y
self.z = vec.z
}
func length() -> Float {
return sqrtf(x * x + y * y + z * z)
}
mutating func setLength(_ length: Float) {
self.normalize()
self *= length
}
mutating func setMaximumLength(_ maxLength: Float) {
if self.length() <= maxLength {
return
} else {
self.normalize()
self *= maxLength
}
}
mutating func normalize() {
self = self.normalized()
}
func normalized() -> SCNVector3 {
if self.length() == 0 {
return self
}
return self / self.length()
}
static func positionFromTransform(_ transform: matrix_float4x4) -> SCNVector3 {
return SCNVector3Make(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z)
}
func friendlyString() -> String {
return "(\(String(format: "%.2f", x)), \(String(format: "%.2f", y)), \(String(format: "%.2f", z)))"
}
func dot(_ vec: SCNVector3) -> Float {
return (self.x * vec.x) + (self.y * vec.y) + (self.z * vec.z)
}
func cross(_ vec: SCNVector3) -> SCNVector3 {
return SCNVector3(self.y * vec.z - self.z * vec.y, self.z * vec.x - self.x * vec.z, self.x * vec.y - self.y * vec.x)
}
}
extension SCNVector3{
func distance(receiver:SCNVector3) -> Float{
let xd = receiver.x - self.x
let yd = receiver.y - self.y
let zd = receiver.z - self.z
let distance = Float(sqrt(xd * xd + yd * yd + zd * zd))
if (distance < 0){
return (distance * -1)
} else {
return (distance)
}
}
}
Here is the code snippet to convert tap location or any CGPoint to world transform.
#objc func handleTap(_ sender: UITapGestureRecognizer) {
// Take the screen space tap coordinates and pass them to the hitTest method on the ARSCNView instance
let tapPoint = sender.location(in: sceneView)
let result = sceneView.hitTest(tapPoint, types: ARHitTestResult.ResultType.existingPlaneUsingExtent)
// If the intersection ray passes through any plane geometry they will be returned, with the planes
// ordered by distance from the camera
if (result.count > 0) {
// If there are multiple hits, just pick the closest plane
if let hitResult = result.first {
let finalPosition = SCNVector3Make(hitResult.worldTransform.columns.3.x + insertionXOffset,
hitResult.worldTransform.columns.3.y + insertionYOffset,
hitResult.worldTransform.columns.3.z + insertionZOffset
);
}
}
}
Following is the code to get hit test results when there's no plane found.
// check what nodes are tapped
let p = gestureRecognize.location(in: scnView)
let hitResults = scnView.hitTest(p, options: [:])
// check that we clicked on at least one object
if hitResults.count > 0 {
// retrieved the first clicked object
let result = hitResults[0]
}
This answer is a bit late but can be useful for someone needing to know where a node is in camera space relatively to the center (e.g. top left corner, centered ...).
You can get your node position in camera space using scene.rootNode.convertPosition(node.position, to: pointOfView).
In camera space,
(isVisible && (x=0, y=0)) means that your node is in front of the camera.
(isVisible && (x=0.1)) means that the node is a little bit on the right.
Some sample code :
public func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
deltaTime = time - lastUpdateTime
if deltaTime>0.25{
if let annotation = annotationsByNode.first {
guard let pointOfView = renderer.pointOfView else {return}
let node = annotation.key.childNodes[0]
let isVisible = renderer.isNode(node, insideFrustumOf: pointOfView)
// Translate node to camera space
let nodeInCameraSpace = scene.rootNode.convertPosition(node.position, to: pointOfView)
let isCentered = isVisible && (nodeInCameraSpace.x < 0.1) && (nodeInCameraSpace.y < 0.1)
let isOnTheRight = isVisible && (nodeInCameraSpace.x > 0.1)
// ...
delegate?.nodePosition?(node:node, pos: dir)
}else {
delegate?.nodePosition?(node:nil, pos: lastDir )
}
lastUpdateTime = time
}