I am trying to implement a multi-row sequence of items (like Video editing sequence in Final Cut Pro or Adobe Premiere pro shown below).
While I one can always implement it using UIScrollView and placing custom views manually, it would be tedious particularly in reordering items and animating changes and also zooming across the timeline using pinch gesture. Is it possible to implement it using UICollectionView using UICollectionViewCompositionalLayout and UICollectionViewDiffableDataSource? From WWDC videos, it seems almost everything is possible using compositional layout but it isn't clear if it is possible to implement a timeline using it. Maybe UICollectionView is not the right paradigm for this use case and one should use UIScrollView? Even if I use UIScrollView, managing things like dragging & reordering items, animating datasource changes, trimming items, zooming the content are going to be issues. Any pointers to existing code base that implements these features?
Here is my playground code as a partial answer for a simple empty iOS Playground file. It should give you a basic idea how to implement it using SpriteKit. I didn't add any animations and the scene so far has a fixed width and the "camera" is also fixed and doesn't allow zooming yet. But I wanted to give you something so you can decided if this is even the right solution for you.
import UIKit
import SpriteKit
import PlaygroundSupport
class MyViewController: UIViewController {
override func loadView() {
// Setting up a basic UIView as parent
let parentView = UIView()
parentView.frame = CGRect(x: 0, y: 0, width: 600, height: 600)
parentView.backgroundColor = .black
// Defining the SKView
let tracksSKView = SKView(frame: parentView.frame)
tracksSKView.ignoresSiblingOrder = false
// Options to debug visually
// tracksSKView.showsNodeCount = true
// tracksSKView.showsPhysics = true
// tracksSKView.showsFields = true
// tracksSKView.showsLargeContentViewer = true
// Defining our subclassed SKScene
let scene = GameScene(size: tracksSKView.bounds.size)
// Presenting and adding views and sceens
tracksSKView.presentScene(scene)
parentView.addSubview(tracksSKView)
self.view = parentView
}
}
//MARK: - Custom SKScene
class GameScene: SKScene {
let trackSize = CGSize(width: 2048, height: 120)
let tracksCount = 4
// Hardcoded clips, use your data source and update when a clip has been moved in any way.
let clips: [Clip] = [
Clip(name: "SongA", track: 1, xPosition: 0, lengh: 245),
Clip(name: "SongB", track: 2, xPosition: 200, lengh: 166, color: .blue),
Clip(name: "SongC", track: 3, xPosition: 200, lengh: 256, color: .red)
]
var touchingClip = false
var touchedClip = SKNode()
// Bacically like loadView or viewDidLoad
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
self.size = CGSize(width: 1024, height: 768)
self.name = "scene"
addTracks(amount: tracksCount)
addClips(clips: clips)
}
// Adding x amount of tracks.
func addTracks(amount: Int) {
for n in 0..<amount {
let trackNode = SKSpriteNode(color: n%2 == 0 ? .systemGray : .systemGray2, size: trackSize)
// Setting up physical propeties of the border of the track
trackNode.physicsBody = SKPhysicsBody(edgeLoopFrom: trackNode.frame)
trackNode.physicsBody?.restitution = 0.2
trackNode.physicsBody?.allowsRotation = false
trackNode.physicsBody?.affectedByGravity = false
trackNode.physicsBody?.isDynamic = false
// Positioning the track
trackNode.zPosition = -1
trackNode.position.y = frame.minY + trackSize.height / 2 + CGFloat(n) * trackSize.height
addChild(trackNode)
}
}
// Adding the Clip objects stored in an array.
func addClips(clips: [Clip]) {
for clip in clips {
let clipNode = SKSpriteNode(color: clip.color, size: CGSize(width: clip.lengh, height: Int(trackSize.height) - 20))
clipNode.position.x = clip.xPosition + CGFloat(clip.lengh / 2)
clipNode.position.y = frame.minY + (trackSize.height * CGFloat(clip.track)) + 1
clipNode.zPosition = 1
clipNode.physicsBody = SKPhysicsBody(rectangleOf: clipNode.frame.size)
clipNode.physicsBody?.affectedByGravity = true
clipNode.physicsBody?.allowsRotation = false
clipNode.physicsBody?.restitution = 0.2
addChild(clipNode)
}
}
//MARK: - User interaction
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
let location = touch.location(in: self)
// getting all nodes the user touched (visible and hidden below others.
let tappedNodes = nodes(at: location)
//getting the top node
if let node = tappedNodes.first {
touchedClip = node
touchingClip = true
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touchingClip else { return }
// Moving the clip (node) based on the movement of the touch. It's very basic and can look jittery. Using the animate methods would create better results.
for touch in touches {
let location = touch.location(in: self)
touchedClip.position = location
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
touchingClip = false
}
}
//MARK: - Interaction in between object like collisions etc.
extension GameScene: SKPhysicsContactDelegate {
// handle different contact cases here
}
//MARK: - Clip object
struct Clip {
var name: String
var track: Int
var xPosition: CGFloat
var lengh: Int
var color: UIColor = .green
}
PlaygroundPage.current.liveView = MyViewController()
I've added a gesture recognizer for a long press to move the clips, while touch and pan is not resizing the clip. Here is the new code:
import UIKit
import SpriteKit
import PlaygroundSupport
PlaygroundPage.current.liveView = MyViewController()
class MyViewController: UIViewController {
override func loadView() {
// Setting up a basic UIView as parent
let parentView = UIView()
parentView.frame = CGRect(x: 0, y: 0, width: 600, height: 600)
parentView.backgroundColor = .black
// Defining the SKView
let tracksSKView = SKView(frame: parentView.frame)
tracksSKView.ignoresSiblingOrder = false
// Options to debug visually
tracksSKView.showsNodeCount = true
tracksSKView.showsPhysics = true
tracksSKView.showsFields = true
tracksSKView.showsLargeContentViewer = true
// Defining our subclassed SKScene
let scene = GameScene(size: tracksSKView.bounds.size)
// Presenting and adding views and sceens
tracksSKView.presentScene(scene)
parentView.addSubview(tracksSKView)
self.view = parentView
}
}
//MARK: - Custom SKScene
class GameScene: SKScene {
let trackSize = CGSize(width: 2048, height: 120)
let tracksCount = 4
// Hardcoded clips, use your data source and update when a clip has been moved in any way.
let clips: [Clip] = [
Clip(name: "SongA", track: 1, xPosition: 0, lengh: 245),
Clip(name: "SongB", track: 2, xPosition: 200, lengh: 166, color: .blue),
Clip(name: "SongC", track: 3, xPosition: 200, lengh: 256, color: .red)
]
// Different interactions, I used a sepperate variable for each interaction instead of one to be able to add more later.
var touchingClip = false
var movingClip = false
var resizingClip = true
var touchedClip = SKNode()
var location = CGPoint()
// Bacically like loadView or viewDidLoad
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
// Using the UI gesture recognizer in the case of a long press seemed easier than trying to figure out the gestures in the touches methods.
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(GameScene.longPress))
self.view!.addGestureRecognizer(longPressRecognizer)
// Adding tracks and clips
addTracks(amount: tracksCount)
addClips(clips: clips)
}
// Method that handles the long press
@objc func longPress(sender: UILongPressGestureRecognizer) {
if sender.state == .began || sender.state == .changed {
movingClip = true
resizingClip = false
} else {
movingClip = false
resizingClip = true
}
location = sender.location(in: self.view)
}
//MARK: - Setting up the tracks and clips
// Adding x amount of tracks.
func addTracks(amount: Int) {
for n in 0..<amount {
let trackNode = SKSpriteNode(color: n%2 == 0 ? .systemGray : .systemGray2, size: trackSize)
// Setting up physical propeties of the border of the track
trackNode.physicsBody = SKPhysicsBody(edgeLoopFrom: trackNode.frame)
trackNode.physicsBody?.restitution = 0.2
trackNode.physicsBody?.allowsRotation = false
trackNode.physicsBody?.affectedByGravity = false
trackNode.physicsBody?.isDynamic = false
// Positioning the track
trackNode.zPosition = -1
trackNode.position.y = frame.minY + trackSize.height / 2 + CGFloat(n) * trackSize.height
addChild(trackNode)
}
}
// Adding the Clip objects stored in an array.
func addClips(clips: [Clip]) {
for clip in clips {
let clipNode = SKSpriteNode(color: clip.color, size: CGSize(width: clip.lengh, height: Int(trackSize.height) - 20))
clipNode.name = clip.name
clipNode.position.x = clip.xPosition + CGFloat(clip.lengh / 2)
clipNode.position.y = frame.minY + (trackSize.height * CGFloat(clip.track)) + 1
clipNode.zPosition = 1
clipNode.physicsBody = SKPhysicsBody(rectangleOf: clipNode.frame.size)
clipNode.physicsBody?.affectedByGravity = true
clipNode.physicsBody?.allowsRotation = false
clipNode.physicsBody?.restitution = 0.2
clipNode.physicsBody?.isDynamic = true
addChild(clipNode)
}
}
//MARK: - User interaction
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touches.first != nil else { return }
for touch in touches {
let location = touch.location(in: self)
touchedClip = atPoint(location) as! SKSpriteNode
if clips.contains(where: { $0.name == touchedClip.name }) {
touchingClip = true
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard touchingClip else { return }
for touch in touches {
if resizingClip {
let resizeValue = touch.location(in: touchedClip).x - touch.previousLocation(in: touchedClip).x
// Checking that we're only adding width to the clip or trimming no more then the remaining width.
if resizeValue > 0 || (resizeValue < 0 && abs(resizeValue) < touchedClip.frame.size.width) {
let action = SKAction.resize(byWidth: resizeValue, height: 0, duration: 0.0)
action.timingMode = .linear
touchedClip.run(action)
}
}
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
touchingClip = false
resizingClip = true
movingClip = false
}
//MARK: - Scene update
// Runs as long as scene is active once per frame (target of 60 frames per second)
override func update(_ currentTime: TimeInterval) {
// The moving needs to be done in the update method, the touches methods are unresponsive while the gesture recognizer is active.
if movingClip && touchingClip {
let newLocation = convertPoint(fromView: location)
let action = SKAction.move(to: newLocation, duration: 0.1)
action.timingMode = .easeInEaseOut
touchedClip.run(action)
}
// The physics body does not change when the clip node is resized. I'm updating it here.
if resizingClip && touchingClip {
touchedClip.physicsBody = SKPhysicsBody(rectangleOf: touchedClip.frame.size)
touchedClip.physicsBody?.affectedByGravity = true
touchedClip.physicsBody?.allowsRotation = false
touchedClip.physicsBody?.restitution = 0.2
touchedClip.physicsBody?.isDynamic = true
}
}
}
//MARK: - Interaction in between object like collisions etc.
extension GameScene: SKPhysicsContactDelegate {
// handle different contact cases here
}
//MARK: - Clip object
struct Clip {
var name: String
var track: Int
var xPosition: CGFloat
var lengh: Int
var color: UIColor = .green
}
Sources:
www.udemy.com/course/dive-into-spritekit (Pretty good, but not great)
designcode.io (Not recommended)
stackoverflow.com/questions/30337608/detect-long-touch-in-sprite-kit
as well as more SO and Apple Dev :)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With