Adding SKNodes into a ScrollView? - macos

On Android, I've previously used CCScrollView (part of Cocos2D-X) to add nodes to a scrollable container, with all the extras like the elastic bounce, scrollbars, swipe gestures, etc.
But I can't work out how to do this in Xcode. I know there's UIScrollView, but I'm writing for macOS. And there's SKCameraNode, but this seems to target the whole scene, rather than just part of the scene, I think.
I'd like to create a horizontal scrollable container for interactive sprite nodes. I know I could simply make the node move in relation to the mouse's x value, but that's not as pleasing as a simple scrollview.
I guess I could place a second scene within the scrollview, but that seems overkill and a performance hit. There's also third party solutions like SwiftySKScrollView but I'd like to avoid relying on such dependencies.
override func mouseDown(with event: NSEvent) {
scroller.removeAllActions()
scrollerPosX = scroller.position.x - event.location(in: self).x
}
override func mouseDragged(with event: NSEvent) {
mouseMomentum = event.deltaX
for node in nodes(at: event.location(in: self)) {
if node.isMember(of: Scroller.self) {
scroller.position.x = event.location(in: self).x + scrollerPosX
}
}
}
// This needs work to improve the ease out momentum, and a boundary bounce needs adding
override func mouseUp(with event: NSEvent) {
let moveby = SKAction.moveBy(x: mouseMomentum*10, y: 0, duration: 0.3)
moveby.timingMode = .easeOut
scroller.run(moveby)
}

Related

White flashing during orientation change even with black background SwiftUI

I have a ZStack that I set the color to black and then add a VideoPlayer. When I rotate the device there are still flashes of white around the player. I have played with all sorts of ideas and background colors, foreground colors, opacity and nothing has worked. I just want the background to be black so it looks like a smooth rotation. Anybody have any suggestions or fixes? Here's my code:
import Foundation
import SwiftUI
import AVKit
struct VideoDetail: View {
var videoIDString: String
var videoThumbURL: String
#State var player = AVPlayer()
var body: some View {
ZStack {
Color.black
.edgesIgnoringSafeArea(.all)
let videoURL: String = videoIDString
VideoPlayer(player: player)
//.frame(height: 200)
.edgesIgnoringSafeArea(.all)
.onAppear {
player = AVPlayer(url: URL(string: videoURL)!)
player.play()
}
.onDisappear {
player.pause()
}
}
.navigationBarHidden(true)
.background(Color.black.edgesIgnoringSafeArea(.all))
}
}
I was having the same issue and came across a solution: you can set the background color of the hosting window's root view controller's view. You don't have direct access to this within SwiftUI, so in order to do this you can use a method described in this answer.
Just copy the withHostingWindow View extension including HostingWindowFinder somewhere and use the following code in your view to set the background color to black:
var body: some View {
ZStack {
// ...
}
.withHostingWindow { window in
window?.rootViewController?.view.backgroundColor = UIColor.black
}
}
After this, the white corners when rotating should be gone!
Just add
ZStack{
...
}.preferredColorScheme(ColorScheme.dark)
I feel your pain. This is a SwiftUI bug. The way that SwiftUI currently works is that it contains your view tree within a UIKit view. For the most part SwiftUI and UIKit cooperate with one another pretty well, but one particular area that struggles seems to be synchronising UIKit and SwiftUI animations.
Therefore, when the device rotates, it's actually UIKit driving the animation, so SwiftUI has to make a best guess of where it might be on the animation curve but its guess is pretty poor.
The best thing we can do right now is file feedback. Duplicated bug reports are how Apple prioritise what to work on, so the more bug reports from everyone the better. It doesn't have to be long. Title it something like 'SwiftUI animation artefacts on device rotation', and write 'Duplicate of FB10376122' for the description to reference an existing report on the same topic.
Anyway, in the meantime, we can at least grab the UIKit view of the enclosing window and set the background colour on there instead. This workaround is limited as 1) it doesn't change the apparent 'jumpiness' of the above mentioned synchronisation between the UIKit and SwiftUI animations, and 2) will only help if your background is a block colour.
That said, here's a WindowGroup replacement and view modifier pair that ties together this workaround to play as nicely as possible with the rest of SwiftUI.
Example usage:
import SwiftUI
#main
struct MyApp: App {
var body: some Scene {
// Should be at the very top of your view tree within your `App` conforming type
StyledWindowGroup {
ContentView()
// view modifier can be anywhere in your view tree
.preferredWindowColor(.black)
}
}
}
To use, copy the contents below into a file named StyledWindowGroup.swift and add to your project:
import SwiftUI
/// Wraps a regular `WindowGroup` and enables use of the `preferredWindowColor(_ color: Color)` view modifier
/// from anywhere within its contained view tree. Use in place of a regular `WindowGroup`
public struct StyledWindowGroup<Content: View>: Scene {
#ViewBuilder let content: () -> Content
public init(content: #escaping () -> Content) {
self.content = content
}
public var body: some Scene {
WindowGroup {
content()
.backgroundPreferenceValue(PreferredWindowColorKey.self) { color in
WindowProxyHostView(backgroundColor: color)
}
}
}
}
// MARK: - Convenience View Modifer
extension View {
/// Sets the background color of the hosting window.
/// - Note: Requires calling view is contained within a `StyledWindowGroup` scene
public func preferredWindowColor(_ color: Color) -> some View {
preference(key: PreferredWindowColorKey.self, value: color)
}
}
// MARK: - Preference Key
fileprivate struct PreferredWindowColorKey: PreferenceKey {
static let defaultValue = Color.white
static func reduce(value: inout Color, nextValue: () -> Color) { }
}
// MARK: - Window Proxy View Pair
fileprivate struct WindowProxyHostView: UIViewRepresentable {
let backgroundColor: Color
func makeUIView(context: Context) -> WindowProxyView {
let view = WindowProxyView(frame: .zero)
view.isHidden = true
return view
}
func updateUIView(_ view: WindowProxyView, context: Context) {
view.rootViewBackgroundColor = backgroundColor
}
}
fileprivate final class WindowProxyView: UIView {
var rootViewBackgroundColor = Color.white {
didSet { updateRootViewColor(on: window) }
}
override func willMove(toWindow newWindow: UIWindow?) {
updateRootViewColor(on: newWindow)
}
private func updateRootViewColor(on window: UIWindow?) {
guard let rootViewController = window?.rootViewController else { return }
rootViewController.view.backgroundColor = UIColor(rootViewBackgroundColor)
}
}

How to compute MKMapCamera centerCoordinateDistance programmatically in SwiftUI

I have a SwiftUI MapKitView that follows the pattern in https://www.hackingwithswift.com/books/ios-swiftui/advanced-mkmapview-with-swiftui adapted for macOS. In makeNSView I set the region for the interesting bit of the location I want to display, irrelevant of windows size and I can get this code to zoom appropriately by default whether the NSWindow is more landscape than portrait or vice versa.
func makeNSView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
mapView.mapType = .satellite
mapView.pointOfInterestFilter = .excludingAll
let region = MKCoordinateRegion(center: centroid, latitudinalMeters: 1160, longitudinalMeters: 1260)
let fittedRegion = mapView.regionThatFits(region)
mapView.setRegion(fittedRegion, animated: false)
}
It appears thatmapView.region is not actually updated upon the return of setRegion()
The rub is I want to orient the map other than true north, so I have to set a camera.
However, the fromDistance: parameter in creating MKMapCamera has to be computed from the region that was set, but what is the camera's field of view angle to determine how high it needs to be to include the correct extent for the window once the region is set? I basically want the zoom level set the same as via the fittedRegion and want to replicate that in the camera with the changed heading (with pitch at 0)
It appears the MKMapViewDelegate has a mapViewDidChangeVisibleRegion and I think the Coordinator is the delegate in SwiftUI. I can see the region on multiple calls to updateNSView, tho it takes a few calls before its actually set. I suspect setting the camera there will create another updateNSView() call which would pose problems.
How can I orient the map to include a given region extent regardless of window size with the zoom level and heading on initial load (but then lets the user manipulate as they see fit)..
Try the following
func makeNSView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
mapView.mapType = .satellite
mapView.pointOfInterestFilter = .excludingAll
let region = MKCoordinateRegion(center: centroid, latitudinalMeters: 1160, longitudinalMeters: 1260)
let fittedRegion = mapView.regionThatFits(region)
DispatchQueue.main.async {
mapView.setRegion(fittedRegion, animated: false)
}
return mapView
}
I think I figured it out.
Responding to func mapViewDidFinishLoadingMap(_ mapView: MKMapView) in the Coordinator and setting the mapView.camera.heading to an appropriate value there does what I'm intending.

iOS Autolayout: Oddly expand-animation of UITextView inside an UIScrollView

I'm trying to animate the height constraint of a UITextView inside a UIScrollView. When the user taps the "toggle" button, the text should appear in animation from top to bottom. But somehow UIKit fades in the complete view.
To ensure the "dynamic" height depending on the intrinsic content-size, I deactivate the height constraint set to zero.
#IBAction func toggle() {
layoutIfNeeded()
UIView.animate(withDuration: 0.6, animations: { [weak self] in
guard let self = self else {
return
}
if self.expanded {
NSLayoutConstraint.activate([self.height].compactMap { $0 })
} else {
NSLayoutConstraint.deactivate([self.height].compactMap { $0 })
}
self.layoutIfNeeded()
})
expanded.toggle()
}
The full code of this example is available on my GitHub Repo: ScrollAnimationExample
Reviewing your GitHub repo...
The issue is due to the view that is animated. You want to run .animate() on the "top-most" view in the hierarchy.
To do this, you can either create a new property of your ExpandableView, such as:
var topMostView: UIView?
and then set that property from your view controller, or...
To keep your class encapsulated, let it find the top-most view. Replace your toggle() func with:
#IBAction func toggle() {
// we need to run .animate() on the "top" superview
// make sure we have a superview
guard self.superview != nil else {
return
}
// find the top-most superview
var mv: UIView = self
while let s = mv.superview {
mv = s
}
// UITextView has subviews, one of which is a _UITextContainerView,
// which also has a _UITextCanvasView subview.
// If scrolling is disabled, and the TextView's height is animated to Zero,
// the CanvasView's height is instantly set to Zero -- so it disappears instead of animating.
// So, when the view is "expanded" we need to first enable scrolling,
// and then animate the height (to Zero)
// When the view is NOT expanded, we first disable scrolling
// and then animate the height (to its intrinsic content height)
if expanded {
textView.isScrollEnabled = true
NSLayoutConstraint.activate([height].compactMap { $0 })
} else {
textView.isScrollEnabled = false
NSLayoutConstraint.deactivate([height].compactMap { $0 })
}
UIView.animate(withDuration: 0.6, animations: {
mv.layoutIfNeeded()
})
expanded.toggle()
}

NSView drawing explained

I am trying to create a object that can be dragged and rotated in an NSView and have been successful in doing so using NSBezierPath. I am creating multiple objects and storing them in a class and using NSBezierPath.transform(using: AffineTransform) to modify the path in response to drag and rotation inputs.
This all works fine but I now want to add text to the shape and it seems there are a different set of rules for dealing with text.
I have tried using Core Text by creating a CTFrame but have no idea how to move or rotate this.
Is there a good reason for why the handling of text is so different from NSBezierPath.
And then there is the difference between AffineTransform and CGAffineTransform. The whole thing is pretty confusing and good documentation explaining the difference seems hard to come by.
Below is the code for creating and moving the shape which seems work perfectly. I have no idea how to move the text, ideally without having to recreate it. Is there any way to translate and rotate the CTFrame?
var path: NSBezierPath
var location: NSPoint {
didSet {
// move()
}
}
var angle: CGFloat {
didSet {
let dx = angle - oldValue
rotate(dx)
}
}
func createPath(){
// Create a simple path with a rectangle
self.path = NSBezierPath(rect: NSRect(x: -1*width/2.0, y: -1*height/2.0, width: width, height: height))
let line = NSBezierPath()
line.move(to: NSPoint(x: width/2.0, y:0))
line.line(to: NSPoint(x: width/2.0+leader, y:0))
self.path.append(line)
// Label !!
let rect = NSRect(x: width/2.0, y: 0, width: leader, height: height/2.0)
let attrString = NSAttributedString(string: assortmentLabel, attributes: attributesForLeftText)
self.labelFrame = textFrame(attrString: attrString, rect: rect)
// ??? How to rotate the CTFrame - is this even possible
move()
rotate(angle)
}
func rotate(_ da: CGFloat){
// Move to origin
let loc = AffineTransform(translationByX: -location.x, byY: -location.y)
self.path.transform(using: loc)
let rotation = AffineTransform(rotationByDegrees: da)
self.path.transform(using: rotation)
// Move back
self.path.transform(using: AffineTransform(translationByX: location.x, byY: location.y))
}
func move(){
let loc = AffineTransform(translationByX: location.x, byY: location.y)
self.path.transform(using: loc)
}
func draw(){
guard let context = NSGraphicsContext.current?.cgContext else {
return
}
color.set()
path.stroke()
if isSelected {
path.fill()
}
if let frame = self.labelFrame {
CTFrameDraw(frame, context)
}
}
// -----------------------------------
// Modify the item location
// -----------------------------------
func offsetLocationBy(x: CGFloat, y:CGFloat)
{
location.x=location.x+x
location.y=location.y+y
let loc = AffineTransform(translationByX: x, byY: y)
self.path.transform(using: loc)
}
EDIT:
I have changed things around a bit to now draw the shape at origin 0,0 and to then apply the transformation to CGContext prior to drawing the shape.
This does the job and using CTDrawFrame now works correctly...
Well almost...
On my test app it works perfectly but when I integrated the exact same code to the production app the text appears upside-down and all characters shown on top of each other.
As far as I can tell there is nothing different about the views the drawing is taking place in - uses the same NSView subclass.
Is there something else that could upset the drawing of the text - seems like an isFlipped issue but why would this happen in the one app and not the other.
Everything else seems to draw correctly. Tearing my hair out on this.
After much struggling it seems I got lucky with the test app working at all and needed to set the textMatrix on BOTH apps to ensure things work properly under all conditions.
It also seems one can't create the CTFrame and later just scale it and redraw it - well it didn't work for me so I had to recreate it in the draw() method each time!

Swift animations appear to be working in reverse

I'm very new to iOS/xcode development, I'm trying to do something very basic as part of a tutorial exercise which is to fade a UILabel in once a user has completed a game.
So I'm hiding the label when the view loads:
override func viewDidLayoutSubviews() {
winMessageLabel.alpha = 0
}
And then on completion of the game I'm trying to fade it back in:
UIView.animateWithDuration(1, animations: { () -> Void in
self.winMessageLabel.alpha = 1
self.view.layoutIfNeeded()
})
But rather than fade in it appears with alpha 1 and then fades out. I get the same behaviour if I try to animate the x/y coordinates whereby it jumps to where I want it to go and then animates back to its original position
I've just worked it out - the layout is invalid so if I call layoutIfNeeded() prior to my animation it sorts everything out and then the label animates as expected:
self.view.layoutIfNeeded()
UIView.animateWithDuration(1, animations: { () -> Void in
self.winMessageLabel.alpha = 1
self.view.layoutIfNeeded()
})

Resources