Possiblity to set scrollbar programmaticaly with dreis ScrollControls?

Hi everyone,

I have three <Html> screens in my scene which are inside of a <ScrollControls>. I don’t include them in a <Scroll> because i actually just want to change the camera position on scrolling and am using useScroll for that. The code works.

I now wanted to implement a snapping mechanism so that if i am scrolling in between two screens, after some period of time (instantly would even be better!), the application automatically scrolls to the nearest screen.

For that i wrote this little function:

const theta = () => {
    const normal = data.offset * size;
    if( time - lastTime >= 4 ) {
        if(lastOffset !== data.offset){
            lastTime = time;
            lastOffset = data.offset;
            return normal;
        }

        let closest;
        let closestPoint;
        animPoints.forEach( animPoint => {
            const offsetAngle = size * data.offset;
            const diff = Math.abs(animPoint - offsetAngle);
					
            if(closest == null || diff < closest){
                closest = diff;
                closestPoint = animPoint;
            }
        })

        data.offset = closestPoint / size;
        return closestPoint;
    }
    return normal;
}

theta() is calculating my angle which i then pass to a function for rotating the HTML screens relative to an anchor.
After some debugging i realised that my values are actually fine and everything is working as it should. I think the problem is that setting data.offset to a value is not scrolling the scrollbar. Is there a solution for this using <ScrollControls/>?

And some further questions: Is there an easier way to achieve this? And if it isn’t solvable using <ScrollControls/> because of the described limitation, how can i solve this (using r3f)?

Here an overview of the current situation:

PS: Naming of the variables could be suboptimal

scrollcontrols just reacts to scrollTop/Left, so you can just set it directly on the scrollcontainer and it will animate there.

1 Like
<div className="App">
  <Canvas shadowMap>
      <ScrollControls scrollLeft={1000} damping={10} horizontal pages={screenDescr.length - 1}>
        <Screens/>
      </ScrollControls>
  </Canvas>
</div>
function Screens() {
    const data = useScroll();

    const animPoints = Array(screenDescr.length)
        .fill()
        .map((_,i) => i * 2 * Math.PI / screenDescr.length)

    const size = 2 * Math.PI * (screenDescr.length-1) / screenDescr.length;

    let lastOffset = data.offset;
    let lastTime = 0;
    let time = 0;

    useFrame((state, delta) => {
        time += delta;

        const theta = () => {
            const normal = data.offset * size;
            if( time - lastTime >= 4 ) {
                if(lastOffset !== data.offset){
                    lastTime = time;
                    lastOffset = data.offset;
                    return normal;
                }

                let closest;
                let closestPoint;
                animPoints.forEach( animPoint => {
                    const offsetAngle = size * data.offset;
                    const diff = Math.abs(animPoint - offsetAngle);
                    
                    if(closest == null || diff < closest){
                        closest = diff;
                        closestPoint = animPoint;
                    }
                })

                data.offset = closestPoint / size;
                return closestPoint;
            }

            return normal;
        }

        rotateAboutPointImmutable(
            state.camera,
            new THREE.Vector3(0,0,0),
            new THREE.Vector3(0,1,0),
            theta(),
            false,
            new THREE.Vector3(0,0,5),
            new THREE.Vector3(0,0,0)
        )
    })

    return screenDescr.map((screen, i) => <Screen 
            key={i} 
            index={i} 
            underlyingHtml={screen.underlyingHtml}
            {...screen}
        />
    )
}

The changes made by the scrollLeft property isn’t used… Do you have any idea why? (And the code seems to be really computation-heavy, my mac book is literally dying on execution :sweat_smile:)

with scrollLeft i mean the css property on the dom scrollcontainer. i believe you get the scroll container in the useScroll data, i think it was called “el” but either way, it is a div which is set to overflow-x/y: scroll. scrollLeft on the ScrollControls component does nothing.

1 Like

Works like a charm!
Here the working code:

function Screens() {
	const data = useScroll();
	const scrollTimer = useRef();

	const animPoints = Array(screenDescr.length)
		.fill()
		.map((_,i) => i * 2 * Math.PI / screenDescr.length)

	const size = 2 * Math.PI * (screenDescr.length-1) / screenDescr.length;

	useEffect(() => {
		data.el.onscroll = () => {
			if(scrollTimer.current !== null){
				clearTimeout(scrollTimer.current);
			}
	
			scrollTimer.current = setTimeout(() => {
				let closest;
				let closestPoint;
				animPoints.forEach( animPoint => {
					const offsetAngle = size * data.offset;
					const diff = Math.abs(animPoint - offsetAngle);
						
					if(closest == null || diff < closest){
						closest = diff;
						closestPoint = animPoint;
					}
				})
	
				data.el.scrollLeft = closestPoint / size * (data.el.scrollWidth - data.el.clientWidth);
			}, 100);
		}
	}, [animPoints, data, size])

	useFrame(state => {
		rotateAboutPointImmutable(
			state.camera,
			new THREE.Vector3(0,0,0),
			new THREE.Vector3(0,1,0),
			data.offset * size,
			false,
			new THREE.Vector3(0,0,5),
			new THREE.Vector3(0,0,0)
		)
	})

	return screenDescr.map((screen, i) => <Screen 
			key={i} 
			index={i} 
			underlyingHtml={screen.underlyingHtml}
			{...screen}
		/>
	)
}

with

export function rotateAboutPointImmutable(obj, point, axis, theta, pointIsWorld, initialPos, initialRot){
    const position = _.cloneDeep(initialPos);

    pointIsWorld = (pointIsWorld === undefined)? false : pointIsWorld;
  
    if(pointIsWorld){
        obj.parent.localToWorld(obj.position); // compensate for world coordinate
    }
  
    position.sub(point); // remove the offset
    position.applyAxisAngle(axis, theta); // rotate the POSITION
    position.add(point); // re-add the offset
  
    if(pointIsWorld){
        obj.parent.worldToLocal(obj.position); // undo world coordinates compensation
    }
  
    obj.position.set(position.x, position.y, position.z);
    
    obj.setRotationFromAxisAngle(axis, theta);
    obj.rotateX(initialRot.x);
    obj.rotateY(initialRot.y);
    obj.rotateZ(initialRot.z);
}

I guess this could use some further refactoring but for now i’m happy with the result!

Hey, this worked for me too! Thanks so much! I am using ScrollControls in a React-three-fiber project with drei components. I have an animation set up with Theatre.js, and I was trying to create “anchors” to certain points in the overall animation by changing the offset property of useScroll() (i.e. data = useScroll(); data.offset = /* set value */). This was unsuccessful, and instead the above worked by grabbing the underlying scrollTop value. Basic outline of my code:

import { ScrollControls, useScroll } from “@react-three/drei”;
import { editable as e, SheetProvider, PerspectiveCamera, useCurrentSheet } from ‘@theatre/r3f’

const sheet = useCurrentSheet();
const scroll = useScroll();

scroll.el.onscroll = () => {
console.log(“canvas element scrolled”)
console.log(scroll.el.scrollTop)
}

// Set scroll position to anchor point
scroll.el.scrollTop = 30000

// Use to recalculate sequence position for Theatre.js:
useFrame(() => {
// the length of my Theatre.js sequence
const sequenceLength = val(sheet.sequence.pointer.length);

// update the "position" of the playhead in the sequence, as a fraction of its whole length
sheet.sequence.position = scroll.offset * sequenceLength;

});