@konradkierus/react-custom-scroll
Version:
An easily designable, cross browser (!!), custom scroll with ReactJS Animations and scroll rate **exactly** like native scroll
425 lines (370 loc) • 12.8 kB
JavaScript
import React, {Component} from 'react'
import reactDOM from 'react-dom'
import styles from './cs.scss'
const simpleDebounce = (func, delay) => {
let timer
return function() {
clearTimeout(timer)
timer = setTimeout(() => {
func()
}, delay)
}
}
const ensureWithinLimits = (value, min, max) => {
min = (!min && min !== 0) ? value : min
max = (!max && max !== 0) ? value : max
if (min > max) {
console.error('min limit is greater than max limit')
return value
}
if (value < min) {
return min
}
if (value > max) {
return max
}
return value
}
function isEventPosOnDomNode(event, domNode) {
const nodeClientRect = domNode.getBoundingClientRect()
return isEventPosOnLayout(event, nodeClientRect)
}
function isEventPosOnLayout(event, layout) {
return (event.clientX > layout.left &&
event.clientX < layout.right &&
event.clientY > layout.top &&
event.clientY < layout.top + layout.height)
}
class CustomScroll extends Component {
constructor(props) {
super(props)
this.scrollbarYWidth = 0
this.state = {
scrollPos: 0,
onDrag: false
}
this.setRefElement = elmKey => element => {
if (element) {
this[elmKey] = element
}
}
this.hideScrollThumb = simpleDebounce(() => {
this.setState({
onDrag: false,
})
}, 500)
}
componentDidMount() {
if (typeof this.props.scrollTo !== 'undefined') {
this.updateScrollPosition(this.props.scrollTo)
} else {
this.forceUpdate()
}
}
componentWillReceiveProps() {
this.externalRender = true
}
componentDidUpdate(prevProps, prevState) {
const prevContentHeight = this.contentHeight
const prevVisibleHeight = this.visibleHeight
const innerContainer = this.getScrolledElement()
const reachedBottomOnPrevRender = prevState.scrollPos >= prevContentHeight - prevVisibleHeight
this.contentHeight = innerContainer.scrollHeight
this.scrollbarYWidth = innerContainer.offsetWidth - innerContainer.clientWidth
this.visibleHeight = innerContainer.clientHeight
this.scrollRatio = this.contentHeight ? this.visibleHeight / this.contentHeight : 1
this.toggleScrollIfNeeded()
if (this.props.freezePosition || prevProps.freezePosition) {
this.adjustFreezePosition(prevProps)
}
if (typeof this.props.scrollTo !== 'undefined' && this.props.scrollTo !== prevProps.scrollTo) {
this.updateScrollPosition(this.props.scrollTo)
} else if (
this.props.keepAtBottom &&
this.externalRender &&
reachedBottomOnPrevRender
) {
this.updateScrollPosition(this.contentHeight - this.visibleHeight)
}
this.externalRender = false
}
componentWillUnmount() {
document.removeEventListener('mousemove', this.onHandleDrag)
document.removeEventListener('mouseup', this.onHandleDragEnd)
}
adjustFreezePosition(prevProps) {
const innerContainer = this.getScrolledElement()
const contentWrapper = this.contentWrapper
if (this.props.freezePosition) {
contentWrapper.scrollTop = this.state.scrollPos
}
if (prevProps.freezePosition) {
innerContainer.scrollTop = this.state.scrollPos
}
}
toggleScrollIfNeeded() {
const shouldHaveScroll = this.contentHeight - this.visibleHeight > 1
if (this.hasScroll !== shouldHaveScroll) {
this.hasScroll = shouldHaveScroll
this.forceUpdate()
}
}
getScrollTop() {
return this.getScrolledElement().scrollTop
}
updateScrollPosition(scrollValue) {
const innerContainer = this.getScrolledElement()
const updatedScrollTop = ensureWithinLimits(scrollValue, 0, this.contentHeight - this.visibleHeight)
innerContainer.scrollTop = updatedScrollTop
this.setState({
scrollPos: updatedScrollTop
})
}
onClick = event => {
if (!this.hasScroll || !this.isMouseEventOnCustomScrollbar(event) || this.isMouseEventOnScrollHandle(event)) {
return
}
const newScrollHandleTop = this.calculateNewScrollHandleTop(event)
const newScrollValue = this.getScrollValueFromHandlePosition(newScrollHandleTop)
this.updateScrollPosition(newScrollValue)
}
isMouseEventOnCustomScrollbar(event) {
if (!this.customScrollbarRef) {
return false
}
const customScrollElm = reactDOM.findDOMNode(this)
const boundingRect = customScrollElm.getBoundingClientRect()
const customScrollbarBoundingRect = this.customScrollbarRef.getBoundingClientRect()
const horizontalClickArea = this.props.rtl ? {
left: boundingRect.left,
right: customScrollbarBoundingRect.right
} : {
left: customScrollbarBoundingRect.left,
width: boundingRect.right
}
const customScrollbarLayout = Object.assign({}, {
left: boundingRect.left,
right: boundingRect.right,
top: boundingRect.top,
height: boundingRect.height
}, horizontalClickArea)
return isEventPosOnLayout(event, customScrollbarLayout)
}
isMouseEventOnScrollHandle(event) {
if (!this.scrollHandle) {
return false
}
const scrollHandle = reactDOM.findDOMNode(this.scrollHandle)
return isEventPosOnDomNode(event, scrollHandle)
}
calculateNewScrollHandleTop(clickEvent) {
const domNode = reactDOM.findDOMNode(this)
const boundingRect = domNode.getBoundingClientRect()
const currentTop = boundingRect.top + window.pageYOffset
const clickYRelativeToScrollbar = clickEvent.pageY - currentTop
const scrollHandleTop = this.getScrollHandleStyle().top
let newScrollHandleTop
const isBelowHandle = clickYRelativeToScrollbar > (scrollHandleTop + this.scrollHandleHeight)
if (isBelowHandle) {
newScrollHandleTop = scrollHandleTop + Math.min(this.scrollHandleHeight, this.visibleHeight - this.scrollHandleHeight)
} else {
newScrollHandleTop = scrollHandleTop - Math.max(this.scrollHandleHeight, 0)
}
return newScrollHandleTop
}
getScrollValueFromHandlePosition(handlePosition) {
return (handlePosition) / this.scrollRatio
}
getScrollHandleStyle() {
const handlePosition = this.state.scrollPos * this.scrollRatio
this.scrollHandleHeight = this.visibleHeight * this.scrollRatio
return {
height: this.scrollHandleHeight,
top: handlePosition
}
}
adjustCustomScrollPosToContentPos(scrollPosition) {
this.setState({
scrollPos: scrollPosition
})
}
onScroll = event => {
if (this.props.freezePosition) {
return
}
this.hideScrollThumb()
this.adjustCustomScrollPosToContentPos(event.currentTarget.scrollTop)
if (this.props.onScroll) {
this.props.onScroll(event)
}
}
getScrolledElement() {
return this.innerContainer
}
onMouseDown = event => {
if (!this.hasScroll || !this.isMouseEventOnScrollHandle(event)) {
return
}
this.startDragHandlePos = this.getScrollHandleStyle().top
this.startDragMousePos = event.pageY
this.setState({
onDrag: true
})
document.addEventListener('mousemove', this.onHandleDrag)
document.addEventListener('mouseup', this.onHandleDragEnd)
}
onTouchStart = () => {
this.setState({
onDrag: true
})
}
onHandleDrag = event => {
event.preventDefault()
const mouseDeltaY = event.pageY - this.startDragMousePos
const handleTopPosition = ensureWithinLimits(this.startDragHandlePos + mouseDeltaY, 0, this.visibleHeight - this.scrollHandleHeight)
const newScrollValue = this.getScrollValueFromHandlePosition(handleTopPosition)
this.updateScrollPosition(newScrollValue)
}
onHandleDragEnd = e => {
this.setState({
onDrag: false
})
e.preventDefault()
document.removeEventListener('mousemove', this.onHandleDrag)
document.removeEventListener('mouseup', this.onHandleDragEnd)
}
blockOuterScroll = e => {
if (this.props.allowOuterScroll) {
return
}
const contentNode = e.currentTarget
const totalHeight = e.currentTarget.scrollHeight
const maxScroll = totalHeight - e.currentTarget.offsetHeight
const delta = e.deltaY % 3 ? (e.deltaY) : (e.deltaY * 10)
if (contentNode.scrollTop + delta <= 0) {
contentNode.scrollTop = 0
e.preventDefault()
} else if (contentNode.scrollTop + delta >= maxScroll) {
contentNode.scrollTop = maxScroll
e.preventDefault()
}
e.stopPropagation()
}
getInnerContainerClasses() {
if (this.state.scrollPos && this.props.addScrolledClass) {
return `${styles.innerContainer} ${styles.contentScrolled}`
}
return styles.innerContainer
}
getScrollStyles() {
const scrollSize = this.scrollbarYWidth || 20
const marginKey = this.props.rtl ? 'marginLeft' : 'marginRight'
const innerContainerStyle = {
height: (this.props.heightRelativeToParent || this.props.flex) ? '100%' : ''
}
innerContainerStyle[marginKey] = -1 * scrollSize
const contentWrapperStyle = {
height: (this.props.heightRelativeToParent || this.props.flex) ? '100%' : '',
overflowY: this.props.freezePosition ? 'hidden' : 'visible'
}
contentWrapperStyle[marginKey] = this.scrollbarYWidth ? 0 : scrollSize
return {
innerContainer: innerContainerStyle,
contentWrapper: contentWrapperStyle
}
}
getOuterContainerStyle() {
return {
height: (this.props.heightRelativeToParent || this.props.flex) ? '100%' : ''
}
}
getRootStyles() {
const result = {}
if (this.props.heightRelativeToParent) {
result.height = this.props.heightRelativeToParent
} else if (this.props.flex) {
result.flex = this.props.flex
}
return result
}
enforceMinHandleHeight(calculatedStyle) {
const minHeight = this.props.minScrollHandleHeight
if (calculatedStyle.height >= minHeight) {
return calculatedStyle
}
const diffHeightBetweenMinAndCalculated = minHeight - calculatedStyle.height
const scrollPositionToAvailableScrollRatio = this.state.scrollPos / (this.contentHeight - this.visibleHeight)
const scrollHandlePosAdjustmentForMinHeight = diffHeightBetweenMinAndCalculated * scrollPositionToAvailableScrollRatio
const handlePosition = calculatedStyle.top - scrollHandlePosAdjustmentForMinHeight
return {
height: minHeight,
top: handlePosition
}
}
setCustomScrollbarRef = elm => {
if (elm) {
this.customScrollbarRef = elm
}
}
render() {
const scrollStyles = this.getScrollStyles()
const rootStyle = this.getRootStyles()
const scrollHandleStyle = this.enforceMinHandleHeight(this.getScrollHandleStyle())
return (
<div className={`${styles.customScroll} ${this.state.onDrag ? styles.scrollHandleDragged : ''}`}
style={rootStyle}>
<div className={styles.outerContainer}
style={this.getOuterContainerStyle()}
onMouseDown={this.onMouseDown}
onTouchStart={this.onTouchStart}
onClick={this.onClick}>
{this.hasScroll ? (
<div className={styles.positioning}>
<div ref={this.setCustomScrollbarRef}
className={`${styles.customScrollbar} ${ this.props.rtl ? styles.customScrollbarRtl : ''}`}
key="scrollbar">
<div ref={this.setRefElement('scrollHandle')}
className={styles.customScrollHandle}
style={scrollHandleStyle}>
<div className={this.props.handleClass}/>
</div>
</div>
</div>) : null}
<div ref={this.setRefElement('innerContainer')}
className={this.getInnerContainerClasses()}
style={scrollStyles.innerContainer}
onScroll={this.onScroll}
onWheel={this.blockOuterScroll}>
<div className={styles.contentWrapper}
ref={this.setRefElement('contentWrapper')}
style={scrollStyles.contentWrapper}>
{this.props.children}
</div>
</div>
</div>
</div>
)
}
}
try {
const PropTypes = require('prop-types')
CustomScroll.propTypes = {
children: PropTypes.any,
allowOuterScroll: PropTypes.bool,
heightRelativeToParent: PropTypes.string,
onScroll: PropTypes.func,
addScrolledClass: PropTypes.bool,
freezePosition: PropTypes.bool,
handleClass: PropTypes.string,
minScrollHandleHeight: PropTypes.number,
flex: PropTypes.string,
rtl: PropTypes.bool,
scrollTo: PropTypes.number,
keepAtBottom: PropTypes.bool
}
} catch (e) {} //eslint-disable-line no-empty
CustomScroll.defaultProps = {
handleClass: styles.innerHandle,
minScrollHandleHeight: 38
}
module.exports = CustomScroll