UICollectionView for non-grid or semi-grid layout

344 views Asked by At

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?

enter image description here

enter image description here

1

There are 1 answers

9
Marco Boerner On

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 :)