The post is now updated with my full code.
I made a small test project where I’m scrolling eight 2048x1536 background images.
Each image is approx. 150kb in size. The project can be found here. The project is now updated and I've stripped down the code to minimal and added more code comments. The project files are now the same as the code here below.
The problem is that the scrolling of the background images twitches
the first time the images are looping. After all pictures has looped once, the scrolling
is smooth and stays smooth, even at a very high scrolling speed.
I’ve preloaded the images with this code here below, that I call from GameViewController.swift.
The reason I put the eight images in eight different texture atlases is that if I put all
images in one atlas Xcode gives this error when compiling:
“Generate SpriteKit Texture Atlas Error Group”:
“/TextureAtlas: cannot fit input texture into a maximum supported dimension of 2048 x 2048.”
Here is a picture from the project:
Here is my code:
GameViewController.swift:
import UIKit
import SpriteKit
import GameplayKit
class GameViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Preload images from SceneManager.swift
SceneManager.sharedInstance.preloadAssets()
self.startScene()
}
func startScene() {
if let view = self.view as! SKView? {
// Load the SKScene from 'GameScene.sks'
let scene = GameScene(size:CGSize(width: 2048, height: 1536))
// Set the scale mode to scale to fit the window
scene.scaleMode = .aspectFill
// Present the scene
view.presentScene(scene)
view.ignoresSiblingOrder = true
view.showsFPS = true
view.showsNodeCount = true
}
}
override var shouldAutorotate: Bool {
return true
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone {
return .allButUpsideDown
} else {
return .all
}
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Release any cached data, images, etc that aren't in use.
}
override var prefersStatusBarHidden: Bool {
return true
}
}
SceneManager.swift:
import Foundation
import SpriteKit
class SceneManager {
static let sharedInstance = SceneManager()
// Preload from atlas. All images cannot be used into one atlas.
// If all images are put in one atlas, when building Xcode gives error:
// “Generate SpriteKit Texture Atlas Error Group”: “/TextureAtlas: cannot fit input texture into a maximum supported dimension of 2048 x 2048.”
var textureAtlas = [SKTextureAtlas]()
func preloadAssets() {
textureAtlas.append(SKTextureAtlas(named: "BGScrollImagesA"))
textureAtlas.append(SKTextureAtlas(named: "BGScrollImagesB"))
textureAtlas.append(SKTextureAtlas(named: "BGScrollImagesC"))
textureAtlas.append(SKTextureAtlas(named: "BGScrollImagesD"))
textureAtlas.append(SKTextureAtlas(named: "BGScrollImagesE"))
textureAtlas.append(SKTextureAtlas(named: "BGScrollImagesF"))
textureAtlas.append(SKTextureAtlas(named: "BGScrollImagesG"))
textureAtlas.append(SKTextureAtlas(named: "BGScrollImagesH"))
SKTextureAtlas.preloadTextureAtlases(textureAtlas, withCompletionHandler: { () -> Void in
print("PRELOAD COMPLETED")
})
}
}
GameScene.swift:
//////////////////////////////////////////////////////////////////////
// //
// Run this project on a physical device. Change the bundle //
// identifier, if needed. //
// //
// The background images are preloaded from GameViewController //
// and SceneManager. //
// //
// Tap screen to start the scrolling, tap again and the speed //
// increases for every tap. //
// //
// This project makes the background images scroll but there //
// is a twitch in the scrolling when the bg images are 'loaded' //
// for the first time. When all the images has looped once, the //
// scrolling is smooth and stays smooth, even at a very high //
// scrolling speed (tap to increase speed). //
// //
// How do I preload and scroll MANY large images so the twitch //
// can be avoided? //
// //
//////////////////////////////////////////////////////////////////////
import SpriteKit
import GameplayKit
class GameScene: SKScene {
// When the scrolling bg image reaches this x-coordinat, it will be moved to the right side outside the screen
let BG_X_RESET: CGFloat = -1030.0
// The spritenodes (bg images) are stored in this array, this is used when we scroll the images
var bgImagesArray = [SKSpriteNode]()
// This is the scrolling speed of the bg images.
var backgroundSpeed:CGFloat = -15.0
// We use this in 'touchesBegan' to start scrolling the bg images
var scrollAction: SKAction!
override init(size: CGSize) {
super.init(size: size)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func didMove(to view: SKView) {
// Change 'numberOfBgPieces' to the number of bg image we want to scroll.
setupBackgroundPieces(numberOfBgPieces: 8, bgArray: &bgImagesArray)
}
// Make sprites of the bg images and position them correctly
func setupBackgroundPieces(numberOfBgPieces: Int, bgArray: inout [SKSpriteNode]){
for x in 1...numberOfBgPieces {
let bgImageName = "bgImage\(x)"
let bg = SKSpriteNode(imageNamed: bgImageName)
bg.position = CGPoint(x: self.frame.minX + (CGFloat(x-1) * bg.size.width), y: self.size.height / 2)
bg.zPosition = 10
bgArray.append(bg)
self.addChild(bg)
}
}
// This function is called from the update loop
// This moves the bg images to the right side outside the
// screen so that they will scroll again. The bg image is moved when it reaches 'spriteResetXPos' which is x -1030.0
func bgMovementPosition(piecesArray: [SKSpriteNode], spriteResetXPos: CGFloat){
for x in (0..<piecesArray.count){
if piecesArray[x].position.x <= spriteResetXPos {
var index: Int!
if x == 0 {
index = piecesArray.count - 1
} else {
index = x - 1
}
let newPos = CGPoint(x: piecesArray[index].position.x + piecesArray[x].size.width, y: piecesArray[x].position.y)
piecesArray[x].position = newPos
}
}
}
func touchDown(atPoint pos : CGPoint) {
}
func touchMoved(toPoint pos : CGPoint) {
}
func touchUp(atPoint pos : CGPoint) {
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
for t in touches { self.touchDown(atPoint: t.location(in: self)) }
// Start scrolling the background images when we tap screen.
// Each time we tap screen, the scroll speed increases (I don't know if this
// is the best way to increase the scroll speed but that is not relevant in this case
// since the problem occurs after the first screen tap = start scrolling).
scrollAction = SKAction.repeatForever(SKAction.moveBy(x: backgroundSpeed, y: 0, duration: 0.02))
for x in bgImagesArray {
x.run(scrollAction)
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
for t in touches { self.touchMoved(toPoint: t.location(in: self)) }
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
for t in touches { self.touchUp(atPoint: t.location(in: self)) }
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
for t in touches { self.touchUp(atPoint: t.location(in: self)) }
}
override func update(_ currentTime: TimeInterval) {
// Called before each frame is rendered
bgMovementPosition(piecesArray: bgImagesArray, spriteResetXPos: BG_X_RESET)
}
}
I also tried to remove all the eight atlases and put all the images in the Assets.xcassets folder and then preload
the textures with these two functions from GameSceneController.swift:
func preloadAssets() {
for x in 1…8 {
let texture = SKTexture(imageNamed: “bgImage\(x)”)
texture.preload {
print("Texture preloaded")
}
}
}
and also this code, with .preload(completionHandler:
func preloadAssets() {
for x in 1…8 {
let texture = SKTexture(imageNamed: “bgImage\(x)”)
texture.preload(completionHandler: {
print("Texture preloaded")
})
}
}
The project I want to create is a real world game with several scrolling levels
that consist of different background images for each level. So there will be
more than eight 2048x1536 images for each level, depending of the length of the
scrolling on each level. I’m thinking of a game similar to Jetpack Joyride.
I’ve spent days and countless hours on trying out different ways to make
the scrolling work without twitches. I’ve tried the solutions in all
Stack Overflow posts I’ve found.
The closest thing to get this to work is to split the 2048x1536 images into
sixteen smaller 128x1536px pieces. But that results in a lot of nodes and
the twitching still occurs until all images has looped, but the twitching
occurs less frequent but it’s still there.
I’m using an iPad Air (1 gen) with iOS version 10.3.3 (14G60) for testing.
Xcode Version 8.3.3 (8E3004b)
Apple Swift version 3.1 (swiftlang-802.0.53 clang-802.0.42) Target: x86_64-apple-macosx10.9
My test project has a 10.0 deployment target.
The only thing I can think of is that is a preload issue, and that I'm not doing it the right way. The code and the scrolling works perfectly after all the images has looped around one time.
So my question is: “How can I make scrolling of full screen images the right way without the initial twitching, with Swift and SpriteKit?”.
Related
I am trying to build a Mac OSX application that renders several gifs and allows the users to drag and drop them for copying the gifs into some other app. I am using a DragDropImageView (code below) that conforms to an NSImageView to render a gif that is drag-n-drop enabled.
It works fine, except that when I drag and drop the gif into another application, it copies only a single image frame of the gif. My intention is to copy the entire gif file and not just a single image.
I am pretty new to iOS/MacOS development in general, and I am not sure if my approach to building this draggable gif component is correct.
I am building the app using swiftUI, and I use a custom view called GifImageView that converts the DragDropImageView to a swiftUI view.
DragDropImageView.swift
import Cocoa
class DragDropImageView: NSImageView, NSDraggingSource {
/// Holds the last mouse down event, to track the drag distance.
var mouseDownEvent: NSEvent?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
isEditable = false
}
required init?(coder: NSCoder) {
super.init(coder: coder)
// Assure editable is set to true, to enable drop capabilities.
isEditable = true
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
}
// MARK: - NSDraggingSource
// Since we only want to copy the current image we register
// for .Copy operation.
func draggingSession(_: NSDraggingSession,
sourceOperationMaskFor _: NSDraggingContext) -> NSDragOperation {
return NSDragOperation.copy
}
// // Clear the ImageView on delete operation; e.g. the image gets
// // dropped on the trash can in the dock.
// func draggingSession(_: NSDraggingSession, endedAt _: NSPoint,
// operation: NSDragOperation) {
// if operation == .delete {
// image = nil
// }
// }
// Track mouse down events and safe the to the poperty.
override func mouseDown(with theEvent: NSEvent) {
mouseDownEvent = theEvent
}
// Track mouse dragged events to handle dragging sessions.
override func mouseDragged(with event: NSEvent) {
// Calculate the dragging distance...
let mouseDown = mouseDownEvent!.locationInWindow
let dragPoint = event.locationInWindow
let dragDistance = hypot(mouseDown.x - dragPoint.x, mouseDown.y - dragPoint.y)
// Cancel the dragging session in case of an accidental drag.
if dragDistance < 3 {
return
}
guard let image = self.image else {
return
}
let draggingImage = image
// Create a new NSDraggingItem with the image as content.
let draggingItem = NSDraggingItem(pasteboardWriter: image)
// Calculate the mouseDown location from the window's coordinate system to the
// ImageView's coordinate system, to use it as origin for the dragging frame.
let draggingFrameOrigin = convert(mouseDown, from: nil)
// Build the dragging frame and offset it by half the image size on each axis
// to center the mouse cursor within the dragging frame.
let draggingFrame = NSRect(origin: draggingFrameOrigin, size: draggingImage.size)
.offsetBy(dx: -draggingImage.size.width / 2, dy: -draggingImage.size.height / 2)
// Assign the dragging frame to the draggingFrame property of our dragging item.
draggingItem.draggingFrame = draggingFrame
// Provide the components of the dragging image.
draggingItem.imageComponentsProvider = {
let component = NSDraggingImageComponent(key: NSDraggingItem.ImageComponentKey.icon)
component.contents = image
component.frame = NSRect(origin: NSPoint(), size: draggingFrame.size)
return [component]
}
// Begin actual dragging session. Woohow!
beginDraggingSession(with: [draggingItem], event: mouseDownEvent!, source: self)
}
}
GifImageView.swift
import AppKit;
import SwiftUI;
struct GifImageView: NSViewRepresentable {
var image: NSImage
func makeNSView(context: Context) -> DragDropImageView {
let view = DragDropImageView()
view.image = self.image
// view.allowsCutCopyPaste = true
return view
}
func updateNSView(_ view: DragDropImageView, context: Context) {
}
}
struct GifImageView_Previews: PreviewProvider {
static var previews: some View {
GifImageView(image: NSImage(data: (NSDataAsset(name: "tenor")?.data)!)!)
}
}
in my ContentView.swift, I use my GifImageView something like this:
GifImageView(image: *some NSImage*)
I put the different images to show in succession in the assets.xcassets folder as shooter, shooter1, shooter2 and so on but whenever i touch the screen the animation/image shown in the view doesn't change?
Here is my code:
import UIKit
import SpriteKit
class ShooterScene: SKScene
{
var score = 0
var enemyCount = 10
var shooterAnimation = [SKTexture]()
override func didMove(to view: SKView)
{
self.initShooterScene()
}
func initShooterScene()
{
let shooterAtlas = SKTextureAtlas(named: "shooter") // referencing shooter.atlas
for index in 1...shooterAtlas.textureNames.count
{
let imgName = "shooter\(index)"
shooterAnimation += [shooterAtlas.textureNamed(imgName)]
}
}
//Animate the shooter
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let shooterNode = self.childNode(withName: "shooterNode") as? SKSpriteNode
{
let animation = SKAction.animate(with: shooterAnimation, timePerFrame: 0.1)
shooterNode.run(animation)
}
}
}
The sprite node that I use in the view has the name shooterNode but the image doesn't seem to change at all. Any help would be great
edit:
console output:
2020-01-23 17:47:59.470204-0500 Tutorial 31 - Introduction to Sprike
Kit[5666:203542] Metal API Validation Enabled
2020-01-23 17:47:59.715838-0500 Tutorial 31 - Introduction to Sprike
Kit[5666:203542] SKView: ignoreRenderSyncInLayoutSubviews is NO. Call
_renderSynchronouslyForTime without handler
2020-01-23 17:48:11.764763-0500 Tutorial 31 - Introduction to Sprike
Kit[5666:203964] XPC connection interrupted
2020-01-23 17:48:11.765810-0500 Tutorial 31 - Introduction to Sprike
Kit[5666:203968] [connection] Connection interrupted: will attempt to
reconnect
2020-01-23 17:48:11.765887-0500 Tutorial 31 - Introduction to Sprike
Kit[5666:205206] [ServicesDaemonManager] interruptionHandler is
called. -[FontServicesDaemonManager connection]_block_invoke
Message from debugger: Terminated due to signal 15
Here's your code modified. It works for me. Without more code, I don't know how you are setting up your shooter node so I can't replicate your issue.
class ShooterScene: SKScene {
var textures = [SKTexture]()
//Also serves as the name of the texture the node uses.
let NODE_NAME:String = "shooter"
override init(size: CGSize) {
super.init(size: size)
//Better the setup scene in intializer, because didMove(to view) is called multiple times.
//You only want to call the setup method once.
self.setup()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup() {
//Node setup
let shooterNode = SKSpriteNode(imageNamed: NODE_NAME)
shooterNode.position = CGPoint(x: self.frame.midX, y: self.frame.midY)
shooterNode.name = NODE_NAME
addChild(shooterNode)
//Create animation collection
let atlas = SKTextureAtlas(named: NODE_NAME)
//Arrays start at index zero so we need to subtract 1 from the total textures count
let upperBound = atlas.textureNames.count - 1
for index in 1...upperBound {
let textureName = "\(NODE_NAME)\(index)"
textures += [atlas.textureNamed(textureName)]
}
}
//Animate the shooter when screen pressed.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let shooterNode = self.childNode(withName: NODE_NAME) as? SKSpriteNode else { return }
//For animations that are around 60 Frames per second
let FPS:Double = 1/60
let animation = SKAction.animate(with: self.textures, timePerFrame: FPS)
shooterNode.run(animation)
}
}
I am trying to write something really simple - and use SpriteKit on this instance.
The way I did it on other platforms, is by having an invisible child "stick" sticking out a bit. By detecting collision between the invisible "sticks" I can tell wherether the object is close to the wall or not.
I am trying to replicate the same thing using SpriteKit. Of course, I'd prefer to have an invisible "beam" coming out of the object and giving me distance - but that's probably too much hassle.
I'd appriciate any ways to improve on my silly project I got so far.
My Project so far
Thanks.
Here is what I came up with that doesn't involve physics...
Drag the mouse to move the car, and the label in the center updates telling you the distance to the closest wall. Release mouse to reset car.
Very simple example, can be updated to give more accurate measurements.
class GameScene: SKScene {
let car = SKSpriteNode(color: .blue, size: CGSize(width: 50, height: 100))
let label = SKLabelNode(text: "")
func findNearestWall() -> CGFloat {
// you can make this more advanced by using the CGPoint of the frame borders of the car, or even 6+ points for more accuracy
let closestX: CGFloat = {
if car.position.x < 0 { // left wall
return abs(frame.minX - car.position.x)
} else { // right wall
return abs(frame.maxX - car.position.x)
}
}()
let closestY: CGFloat = {
if car.position.y < 0 { // bottom wall
return abs(frame.minY - car.position.y)
} else { // top wall
return abs(frame.maxY - car.position.y)
}
}()
if closestX < closestY {
return closestX.rounded() // as closest wall distance
} else {
return closestY.rounded() // as closest wall distance
}
}
override func didMove(to view: SKView) {
removeAllChildren()
label.fontSize *= 2
addChild(car)
addChild(label)
}
override func mouseDown(with event: NSEvent) {
}
override func mouseDragged(with event: NSEvent) {
let location = event.location(in: self)
car.position = location
}
override func mouseUp(with event: NSEvent) {
car.position = CGPoint.zero
}
override func didEvaluateActions() {
label.text = String(describing: findNearestWall())
}
}
I am trying to pan and zoom across an image background in spritekit, I have managed to get the zoom working ok and manually entered some restrictions on how far you can pan the image, however the problem is when you pan the screen right to the edge of the image and then zoom out the background shows.
I want the camera to restrict only to the image on screen and not any blank background. Any ideas on how I should do this or any better solutions?
Here is what I got so far
class GameScene:SKScene{
var cam: SKCameraNode!
var scaleNum:CGFloat=1
override func didMove(to view: SKView){
cam=SKCameraNode()
cam.setScale(CGFloat(scaleNum))
self.camera=cam
self.addChild(cam)
let gesture=UIPinchGestureRecognizer(target: self, action: #selector(zoomIn(recognizer:)))
self.view!.addGestureRecognizer(gesture)
}
func zoomIn(recognizer: UIPinchGestureRecognizer){
if recognizer.state == .changed{
cam.setScale(recognizer.scale)
scaleNum=recognizer.scale
if cam.xScale<1 || cam.yScale<1{
cam.setScale(1)
}
if cam.xScale>3 || cam.yScale > 3{
cam.setScale(3)
}
// recognizer.scale=1
test()
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let firstTouch=touches.first
let location=(firstTouch?.location(in: self))!
let previousLocation=firstTouch?.previousLocation(in: self)
cam?.position.x -= location.x - (previousLocation?.x)!
cam?.position.y -= location.y - (previousLocation?.y)!
test()
}
func test(){
if cam.position.x < 1000*scaleNum{
cam.position.x=1000*scaleNum
}
if cam.position.x > 9200*scaleNum{
cam.position.x=9200*scaleNum
}
if cam.position.y<617*scaleNum{
cam.position.y=617*scaleNum
}
if cam.position.y>4476*scaleNum{
cam.position.y=4476*scaleNum
}
}
}
First of all, I would change your zoomIn function to this:
func zoomIn(recognizer: UIPinchGestureRecognizer){
if recognizer.state == .changed {
scaleNum = recognizer.scale
if scaleNum < 1 { scaleNum = 1 }
if scaleNum > 3 { scaleNum = 3 }
cam.setScale(scaleNum)
test()
}
}
It is easier to understand, you're not setting the camera scale twice, and most importantly, when you clamp the camera scale, scaleNum reflects that clamped value. This was not the case before, and in fact, that small change might be your entire problem.
Now I don't have much experience with UIPinchGestureRecognizer but I think the reason your zoom gesture works "ok" is because you are assigning directly from recognizer.scale to cam scale. Correct me if I'm wrong, but I think UIGestureRecognizer always starts with a scale of 1 for each new gesture, but your camera scale maintains its last value.
As an example, imagine your camera is at a scale of 1. A user zooms in to a scale of 2, the scene zooms in perfectly. The user lifts their fingers ending the gesture. Then the user tries to zoom in more, so they begin a new gesture, starting with a scale of 1, but your scene is still at a scale of 2. You can't assign the gesture scale directly or the image scale will 'jump' back to 1 for each new gesture. You have to convert from the gesture scale space to the camera scale space.
How exactly you do this is a design and feel choice. With no experience, my advice would be to change the line in my zoomIn function from
`scaleNum = recognizer.scale'
to
`scaleNum *= recognizer.scale`
Try both versions, and let me know how they work. If there is still a problem, then it most likely resides in your test() function. If so, I will try and help out with that as needed.
Thanks for the answer above, I managed to get it working, code below. Still needs a bit of tweaking but you can pan and zoom anywhere on the background image but the view should be constrained within the background image and not move into empty space beyond the image
import SpriteKit
import GameplayKit
class GameScene: SKScene {
var cam: SKCameraNode!
var scaleNum: CGFloat=1
var background: SKSpriteNode!
var playableRect: CGRect!
override func didMove(to view: SKView) {
background=self.childNode(withName: "clouds") as! SKSpriteNode
cam=SKCameraNode()
cam.setScale(CGFloat(scaleNum))
self.camera=cam
self.addChild(cam)
self.isUserInteractionEnabled=true
let gesture=UIPinchGestureRecognizer(target: self, action: #selector(zoomIn(recognizer:)))
self.view!.addGestureRecognizer(gesture)
let maxAspectRatio:CGFloat=16.0/9.0
let playableHeight=size.width/maxAspectRatio
let playableMargin=(size.height-playableHeight)/2.0
playableRect=CGRect(x:0, y: playableMargin, width: size.width, height: playableHeight)
}
func zoomIn(recognizer: UIPinchGestureRecognizer){
if recognizer.state == .changed{
let savedScale=scaleNum
scaleNum=recognizer.scale
if scaleNum<1{
scaleNum=1
}
else if scaleNum>3{
scaleNum=3
}
if testcamera(posX: cam.position.x, posY: cam.position.y){
cam.setScale(scaleNum)
}
else{
scaleNum=savedScale
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let firstTouch=touches.first
let location=(firstTouch?.location(in: self))!
var posX=cam.position.x
var posY=cam.position.y
let previousLocation=firstTouch?.previousLocation(in: self)
posX -= location.x - (previousLocation?.x)!
posY -= location.y - (previousLocation?.y)!
if testcamera(posX: posX, posY: posY){
cam.position.x=posX
cam.position.y=posY
}
}
func testcamera(posX: CGFloat, posY: CGFloat)->Bool{
var cameraRect : CGRect {
let xx = posX - size.width/2*scaleNum
let yy = posY - playableRect.height/2*scaleNum
return CGRect(x: xx, y: yy, width: size.width*scaleNum, height: playableRect.height*scaleNum)
}
let backGroundRect=CGRect(x: background.position.x-background.frame.width/2, y: background.position.y-background.frame.height/2, width: background.frame.width, height: background.frame.height)
return backGroundRect.contains(cameraRect)
}
}
I'm looking to create a shop in my game (In SpriteKit) with buttons and images, but I need the items to be scrollable so the player can scroll up and down the shop (Like a UITableView but with multiple SKSpriteNodes and SKLabelNodes in each cell). Any idea how I can do this in SpriteKit?
The second answer as promised, I just figured out the issue.
I recommend to always get the latest version of this code from my gitHub project incase I made changes since this answer, link is at the bottom.
Step 1: Create a new swift file and paste in this code
import SpriteKit
/// Scroll direction
enum ScrollDirection {
case vertical // cases start with small letters as I am following Swift 3 guildlines.
case horizontal
}
class CustomScrollView: UIScrollView {
// MARK: - Static Properties
/// Touches allowed
static var disabledTouches = false
/// Scroll view
private static var scrollView: UIScrollView!
// MARK: - Properties
/// Current scene
private let currentScene: SKScene
/// Moveable node
private let moveableNode: SKNode
/// Scroll direction
private let scrollDirection: ScrollDirection
/// Touched nodes
private var nodesTouched = [AnyObject]()
// MARK: - Init
init(frame: CGRect, scene: SKScene, moveableNode: SKNode) {
self.currentScene = scene
self.moveableNode = moveableNode
self.scrollDirection = scrollDirection
super.init(frame: frame)
CustomScrollView.scrollView = self
self.frame = frame
delegate = self
indicatorStyle = .White
scrollEnabled = true
userInteractionEnabled = true
//canCancelContentTouches = false
//self.minimumZoomScale = 1
//self.maximumZoomScale = 3
if scrollDirection == .horizontal {
let flip = CGAffineTransformMakeScale(-1,-1)
transform = flip
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - Touches
extension CustomScrollView {
/// Began
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
for touch in touches {
let location = touch.locationInNode(currentScene)
guard !CustomScrollView.disabledTouches else { return }
/// Call touches began in current scene
currentScene.touchesBegan(touches, withEvent: event)
/// Call touches began in all touched nodes in the current scene
nodesTouched = currentScene.nodesAtPoint(location)
for node in nodesTouched {
node.touchesBegan(touches, withEvent: event)
}
}
}
/// Moved
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
for touch in touches {
let location = touch.locationInNode(currentScene)
guard !CustomScrollView.disabledTouches else { return }
/// Call touches moved in current scene
currentScene.touchesMoved(touches, withEvent: event)
/// Call touches moved in all touched nodes in the current scene
nodesTouched = currentScene.nodesAtPoint(location)
for node in nodesTouched {
node.touchesMoved(touches, withEvent: event)
}
}
}
/// Ended
override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
for touch in touches {
let location = touch.locationInNode(currentScene)
guard !CustomScrollView.disabledTouches else { return }
/// Call touches ended in current scene
currentScene.touchesEnded(touches, withEvent: event)
/// Call touches ended in all touched nodes in the current scene
nodesTouched = currentScene.nodesAtPoint(location)
for node in nodesTouched {
node.touchesEnded(touches, withEvent: event)
}
}
}
/// Cancelled
override func touchesCancelled(touches: Set<UITouch>?, withEvent event: UIEvent?) {
for touch in touches! {
let location = touch.locationInNode(currentScene)
guard !CustomScrollView.disabledTouches else { return }
/// Call touches cancelled in current scene
currentScene.touchesCancelled(touches, withEvent: event)
/// Call touches cancelled in all touched nodes in the current scene
nodesTouched = currentScene.nodesAtPoint(location)
for node in nodesTouched {
node.touchesCancelled(touches, withEvent: event)
}
}
}
}
// MARK: - Touch Controls
extension CustomScrollView {
/// Disable
class func disable() {
CustomScrollView.scrollView?.userInteractionEnabled = false
CustomScrollView.disabledTouches = true
}
/// Enable
class func enable() {
CustomScrollView.scrollView?.userInteractionEnabled = true
CustomScrollView.disabledTouches = false
}
}
// MARK: - Delegates
extension CustomScrollView: UIScrollViewDelegate {
func scrollViewDidScroll(scrollView: UIScrollView) {
if scrollDirection == .horizontal {
moveableNode.position.x = scrollView.contentOffset.x
} else {
moveableNode.position.y = scrollView.contentOffset.y
}
}
}
This make a subclass of UIScrollView and sets up the basic properties of it. It than has its own touches method which get passed along to the relevant scene.
Step2: In your relevant scene you want to use it you create a scroll view and moveable node property like so
weak var scrollView: CustomScrollView!
let moveableNode = SKNode()
and add them to the scene in didMoveToView
scrollView = CustomScrollView(frame: CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.height), scene: self, moveableNode: moveableNode, scrollDirection: .vertical)
scrollView.contentSize = CGSizeMake(self.frame.size.width, self.frame.size.height * 2)
view?.addSubview(scrollView)
addChild(moveableNode)
What you do here in line 1 is you init the scroll view helper with you scene dimensions. You also pass along the scene for reference and the moveableNode you created at step 2.
Line 2 is where you set up the content size of the scrollView, in this case its twice as long as the screen height.
Step3: - Add you labels or nodes etc and position them.
label1.position.y = CGRectGetMidY(self.frame) - self.frame.size.height
moveableNode.addChild(label1)
in this example the label would be on the 2nd page in the scrollView. This is where you have to play around with you labels and positioning.
I recommend that if you have a lot pages in the scroll view and a lot of labels to do the following. Create a SKSpriteNode for each page in the scroll view and make each of them the size of the screen. Call them like page1Node, page2Node etc. You than add all the labels you want for example on the second page to page2Node. The benefit here is that you basically can position all your stuff as usual within page2Node and than just position page2Node in the scrollView.
You are also in luck because using the scrollView vertically (which u said you want) you dont need to do any flipping and reverse positioning.
I made some class func so if you need to disable your scrollView incase you overlay another menu ontop of the scrollView.
CustomScrollView.enable()
CustomScrollView.disable()
And finally do not forget to remove the scroll view from your scene before transitioning to a new one. One of the pains when dealing with UIKit in spritekit.
scrollView?.removeFromSuperView()
For horizontal scrolling simply change the scroll direction on the init method to .horizontal (step 2).
And now the biggest pain is that everything is in reverse when positioning stuff. So the scroll view goes from right to left. So you need to use the scrollView "contentOffset" method to reposition it and basically place all your labels in reverse order from right to left. Using SkNodes again makes this much easier once you understand whats happening.
Hope this helps and sorry for the massive post but as I said it is a bit of a pain in spritekit. Let me know how it goes and if I missed anything.
Project is on gitHub
https://github.com/crashoverride777/SwiftySKScrollView
You have 2 options
1) Use a UIScrollView
Down the road this is the better solution as you get things such as momentum scrolling, paging, bounce effects etc for free. However you have to either use a lot of UIKit stuff or do some sub classing to make it work with SKSpritenodes or labels.
Check my project on gitHub for an example
https://github.com/crashoverride777/SwiftySKScrollView
2) Use SpriteKit
Declare 3 class variables outside of functions(under where it says 'classname': SKScene):
var startY: CGFloat = 0.0
var lastY: CGFloat = 0.0
var moveableArea = SKNode()
Set up your didMoveToView, add the SKNode to the scene and add 2 labels, one for the top and one for the bottom to see it working!
override func didMoveToView(view: SKView) {
// set position & add scrolling/moveable node to screen
moveableArea.position = CGPointMake(0, 0)
self.addChild(moveableArea)
// Create Label node and add it to the scrolling node to see it
let top = SKLabelNode(fontNamed: "Avenir-Black")
top.text = "Top"
top.fontSize = CGRectGetMaxY(self.frame)/15
top.position = CGPoint(x:CGRectGetMidX(self.frame), y:CGRectGetMaxY(self.frame)*0.9)
moveableArea.addChild(top)
let bottom = SKLabelNode(fontNamed: "Avenir-Black")
bottom.text = "Bottom"
bottom.fontSize = CGRectGetMaxY(self.frame)/20
bottom.position = CGPoint(x:CGRectGetMidX(self.frame), y:0-CGRectGetMaxY(self.frame)*0.5)
moveableArea.addChild(bottom)
}
Then set up your touches began to store position of your first touch:
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
// store the starting position of the touch
let touch: AnyObject? = touches.anyObject();
let location = touch?.locationInNode(self)
startY = location!.y
lastY = location!.y
}
Then set up touches moved with the following code to scroll the node by to the limits set, at the speed set:
override func touchesMoved(touches: NSSet, withEvent event: UIEvent) {
let touch: AnyObject? = touches.anyObject();
let location = touch?.locationInNode(self)
// set the new location of touch
var currentY = location!.y
// Set Top and Bottom scroll distances, measured in screenlengths
var topLimit:CGFloat = 0.0
var bottomLimit:CGFloat = 0.6
// Set scrolling speed - Higher number is faster speed
var scrollSpeed:CGFloat = 1.0
// calculate distance moved since last touch registered and add it to current position
var newY = moveableArea.position.y + ((currentY - lastY)*scrollSpeed)
// perform checks to see if new position will be over the limits, otherwise set as new position
if newY < self.size.height*(-topLimit) {
moveableArea.position = CGPointMake(moveableArea.position.x, self.size.height*(-topLimit))
}
else if newY > self.size.height*bottomLimit {
moveableArea.position = CGPointMake(moveableArea.position.x, self.size.height*bottomLimit)
}
else {
moveableArea.position = CGPointMake(moveableArea.position.x, newY)
}
// Set new last location for next time
lastY = currentY
}
All credit goes to this article
http://greenwolfdevelopment.blogspot.co.uk/2014/11/scrolling-in-sprite-kit-swift.html
Here's the code we used to simulate UIScrollView behavior for SpriteKit menus.
Basically, you need to use a dummy UIView that matches the height of the SKScene then feed UIScrollView scroll and tap events to the SKScene for processing.
It's frustrating Apple doesn't provide this natively, but hopefully no one else has to waste time rebuilding this functionality!
class ScrollViewController: UIViewController, UIScrollViewDelegate {
// IB Outlets
#IBOutlet weak var scrollView: UIScrollView!
// General Vars
var scene = ScrollScene()
// =======================================================================================================
// MARK: Public Functions
// =======================================================================================================
override func viewDidLoad() {
// Call super
super.viewDidLoad()
// Create scene
scene = ScrollScene()
// Allow other overlays to get presented
definesPresentationContext = true
// Create content view for scrolling since SKViews vanish with height > ~2048
let contentHeight = scene.getScrollHeight()
let contentFrame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: contentHeight)
let contentView = UIView(frame: contentFrame)
contentView.backgroundColor = UIColor.clear
// Create SKView with same frame as <scrollView>, must manually compute because <scrollView> frame not ready at this point
let scrollViewPosY = CGFloat(0)
let scrollViewHeight = UIScreen.main.bounds.size.height - scrollViewPosY
let scrollViewFrame = CGRect(x: 0, y: scrollViewPosY, width: UIScreen.main.bounds.size.width, height: scrollViewHeight)
let skView = SKView(frame: scrollViewFrame)
view.insertSubview(skView, at: 0)
// Configure <scrollView>
scrollView.addSubview(contentView)
scrollView.delegate = self
scrollView.contentSize = contentFrame.size
// Present scene
skView.presentScene(scene)
// Handle taps on <scrollView>
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(scrollViewDidTap))
scrollView.addGestureRecognizer(tapGesture)
}
// =======================================================================================================
// MARK: UIScrollViewDelegate Functions
// =======================================================================================================
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scene.scrollBy(contentOffset: scrollView.contentOffset.y)
}
// =======================================================================================================
// MARK: Gesture Functions
// =======================================================================================================
#objc func scrollViewDidTap(_ sender: UITapGestureRecognizer) {
let scrollViewPoint = sender.location(in: sender.view!)
scene.viewDidTapPoint(viewPoint: scrollViewPoint, contentOffset: scrollView.contentOffset.y)
}
}
class ScrollScene : SKScene {
// Layer Vars
let scrollLayer = SKNode()
// General Vars
var originalPosY = CGFloat(0)
// ================================================================================================
// MARK: Initializers
// ================================================================================================
override init() {
super.init()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// ================================================================================================
// MARK: Public Functions
// ================================================================================================
func scrollBy(contentOffset: CGFloat) {
scrollLayer.position.y = originalPosY + contentOffset
}
func viewDidTapPoint(viewPoint: CGPoint, contentOffset: CGFloat) {
let nodes = getNodesTouchedFromView(point: viewPoint, contentOffset: contentOffset)
}
func getScrollHeight() -> CGFloat {
return scrollLayer.calculateAccumulatedFrame().height
}
fileprivate func getNodesTouchedFromView(point: CGPoint, contentOffset: CGFloat) -> [SKNode] {
var scenePoint = convertPoint(fromView: point)
scenePoint.y += contentOffset
return scrollLayer.nodes(at: scenePoint)
}
}
I like the idea of add a SKCameraNode to scroll my menu-scene. I've founded this article really useful. You just have to change the camera position to move your menu. In Swift 4
var boardCamera = SKCameraNode()
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
let location = touch.location(in: self)
let previousLocation = touch.previousLocation(in: self)
let deltaY = location.y - previousLocation.y
boardCamera.position.y += deltaY
}
}