diff --git a/packages/ui/Marquee/MarqueeController.js b/packages/ui/Marquee/MarqueeController.js index 21c749fb29..dfb39d116c 100644 --- a/packages/ui/Marquee/MarqueeController.js +++ b/packages/ui/Marquee/MarqueeController.js @@ -1,15 +1,5 @@ -import {forward} from '@enact/core/handle'; import hoc from '@enact/core/hoc'; -import {Job} from '@enact/core/util'; -import {createContext, Component} from 'react'; - -const STATE = { - inactive: 0, // Marquee is not necessary (render or focus not happened) - active: 1, // Marquee in progress, awaiting complete - ready: 2 // Marquee completed or not needed, but state is active -}; - -const MarqueeControllerContext = createContext(null); +import {MarqueeControllerContext, useMarqueeController} from './useMarqueeController'; /** * Default configuration parameters for {@link ui/Marquee.MarqueeController}. @@ -40,268 +30,23 @@ const defaultConfig = { */ const MarqueeController = hoc(defaultConfig, (config, Wrapped) => { const {marqueeOnFocus} = config; - const forwardBlur = forward('onBlur'); - const forwardFocus = forward('onFocus'); - return class extends Component { - static displayName = 'MarqueeController'; + return function (props) { + const {handleBlur, handleFocus, provideMarqueeControllerContext} = useMarqueeController(props); + let wrappedProps = props; - constructor (props) { - super(props); - - this.controlled = []; - this.isFocused = false; - this.childContext = { - cancel: this.handleCancel, - complete: this.handleComplete, - enter: this.handleEnter, - leave: this.handleLeave, - register: this.handleRegister, - start: this.handleStart, - unregister: this.handleUnregister + if (marqueeOnFocus) { + wrappedProps = { + ...props, + onBlur: handleBlur, + onFocus: handleFocus }; } - componentWillUnmount () { - this.cancelJob.stop(); - } - - cancelJob = new Job(() => this.doCancel(), 30); - - /* - * Registers `component` with a set of handlers for `start` and `stop`. - * - * @param {Object} component A component, typically a React component instance, on - * which handlers will be dispatched. - * @param {Object} handlers An object containing `start` and `stop` functions - * - * @returns {undefined} - */ - handleRegister = (component, handlers) => { - const needsStart = !this.allInactive() || this.isFocused; - - this.controlled.push({ - ...handlers, - state: STATE.inactive, - component - }); - - if (needsStart) { - this.dispatch('start'); - } - }; - - /* - * Unregisters `component` for synchronization - * - * @param {Object} component A previously registered component - * - * @returns {undefined} - */ - handleUnregister = (component) => { - let wasRunning = false; - for (let i = 0; i < this.controlled.length; i++) { - if (this.controlled[i].component === component) { - wasRunning = this.controlled[i].state === STATE.active; - this.controlled.splice(i, 1); - break; - } - } - if (wasRunning && !this.anyRunning()) { - this.dispatch('start'); - } - }; - - /* - * Handler for the `start` context function - * - * @param {Object} component A previously registered component - * - * @returns {undefined} - */ - handleStart = (component) => { - this.cancelJob.stop(); - if (!this.anyRunning()) { - this.markAll(STATE.ready); - this.dispatch('start', component); - } - }; - - /* - * Handler for the `cancel` context function - * - * @param {Object} component A previously registered component - * - * @returns {undefined} - */ - handleCancel = () => { - if (this.anyRunning()) { - this.cancelJob.start(); - } - }; - - doCancel = () => { - if (this.isHovered || this.isFocused) { - return; - } - this.markAll(STATE.inactive); - this.dispatch('stop'); - }; - - /* - * Handler for the `complete` context function - * - * @param {Object} component A previously registered component - * - * @returns {undefined} - */ - handleComplete = (component) => { - const complete = this.markReady(component); - if (complete) { - this.markAll(STATE.ready); - this.dispatch('start'); - } - }; - - handleEnter = () => { - this.isHovered = true; - if (!this.anyRunning()) { - this.dispatch('start'); - } - this.cancelJob.stop(); - }; - - handleLeave = () => { - this.isHovered = false; - this.cancelJob.start(); - }; - - /* - * Handler for the focus event - */ - handleFocus = (ev) => { - this.isFocused = true; - if (!this.anyRunning()) { - this.dispatch('start'); - } - this.cancelJob.stop(); - forwardFocus(ev, this.props); - }; - - /* - * Handler for the blur event - */ - handleBlur = (ev) => { - this.isFocused = false; - if (this.anyRunning()) { - this.cancelJob.start(); - } - forwardBlur(ev, this.props); - }; - - /* - * Invokes the `action` handler for each synchronized component except the invoking - * `component`. - * - * @param {String} action `'start'` or `'stop'` - * @param {Object} component A previously registered component - * - * @returns {undefined} - */ - dispatch (action, component) { - this.controlled.forEach((controlled) => { - const {component: controlledComponent, [action]: handler} = controlled; - if (component !== controlledComponent && typeof handler === 'function') { - const complete = handler.call(controlledComponent); - - // Returning `true` from a start request means that the marqueeing is - // unnecessary and is therefore not awaiting a finish - if (action === 'start' && complete) { - controlled.state = STATE.ready; - } else if (action === 'start') { - controlled.state = STATE.active; - } - } else if ((action === 'start') && (component === controlledComponent)) { - controlled.state = STATE.active; - } - }); - } - - /* - * Marks all components with the passed-in state - * - * @param {Enum} state The state to set - * - * @returns {undefined} - */ - markAll (state) { - this.controlled.forEach(c => { - c.state = state; - }); - } - - /* - * Marks `component` as ready for next marquee action - * - * @param {Object} component A previously registered component - * - * @returns {Boolean} `true` if no components are STATE.active - */ - markReady (component) { - let complete = true; - this.controlled.forEach(c => { - if (c.component === component) { - c.state = STATE.ready; - } - - complete = complete && (c.state !== STATE.active); - }); - - return complete; - } - - /* - * Checks that all components are inactive - * - * @returns {Boolean} `true` if any components should be running - */ - allInactive () { - const activeOrReady = this.controlled.reduce((res, component) => { - return res || !(component.state === STATE.inactive); - }, false); - return !activeOrReady; - } - - /* - * Checks for any components currently marqueeing - * - * @returns {Boolean} `true` if any component is marqueeing - */ - anyRunning () { - return this.controlled.reduce((res, component) => { - return res || (component.state === STATE.active); - }, false); - } - - render () { - let props = this.props; - - if (marqueeOnFocus) { - props = { - ...this.props, - onBlur: this.handleBlur, - onFocus: this.handleFocus - }; - } - - return ( - - - - ); - } + return provideMarqueeControllerContext( + + ); }; - }); export default MarqueeController; diff --git a/packages/ui/Marquee/useMarqueeController.js b/packages/ui/Marquee/useMarqueeController.js new file mode 100644 index 0000000000..0d06f9ecc7 --- /dev/null +++ b/packages/ui/Marquee/useMarqueeController.js @@ -0,0 +1,266 @@ +import {forward} from '@enact/core/handle'; +import {Job} from '@enact/core/util'; +import {createContext, useCallback, useMemo, useEffect, useRef} from 'react'; + +const STATE = { + inactive: 0, // Marquee is not necessary (render or focus not happened) + active: 1, // Marquee in progress, awaiting complete + ready: 2 // Marquee completed or not needed, but state is active +}; + +const MarqueeControllerContext = createContext(null); + +const forwardBlur = forward('onBlur'); +const forwardFocus = forward('onFocus'); + +const useMarqueeController = (props) => { + const mutableRef = useRef({ + controlled: [], + isFocused: false, + isHovered: false + }); + + /* + * Invokes the `action` handler for each synchronized component except the invoking + * `component`. + * + * @param {String} action `'start'` or `'stop'` + * @param {Object} component A previously registered component + * + * @returns {undefined} + */ + const dispatch = useCallback((action, component) => { + mutableRef.current.controlled.forEach((controlled) => { + const {component: controlledComponent, [action]: handler} = controlled; + if (component !== controlledComponent && typeof handler === 'function') { + const complete = handler.call(controlledComponent); + + // Returning `true` from a start request means that the marqueeing is + // unnecessary and is therefore not awaiting a finish + if (action === 'start' && complete) { + controlled.state = STATE.ready; + } else if (action === 'start') { + controlled.state = STATE.active; + } + } else if ((action === 'start') && (component === controlledComponent)) { + controlled.state = STATE.active; + } + }); + }, []); + + /* + * Marks all components with the passed-in state + * + * @param {Enum} state The state to set + * + * @returns {undefined} + */ + const markAll = useCallback((state) => { + mutableRef.current.controlled.forEach(c => { + c.state = state; + }); + }, []); + + /* + * Marks `component` as ready for next marquee action + * + * @param {Object} component A previously registered component + * + * @returns {Boolean} `true` if no components are STATE.active + */ + const markReady = useCallback((component) => { + let complete = true; + mutableRef.current.controlled.forEach(c => { + if (c.component === component) { + c.state = STATE.ready; + } + complete = complete && (c.state !== STATE.active); + }); + return complete; + }, []); + + /* + * Checks that all components are inactive + * + * @returns {Boolean} `true` if any components should be running + */ + const allInactive = useCallback(() => { + const activeOrReady = mutableRef.current.controlled.reduce((res, component) => { + return res || !(component.state === STATE.inactive); + }, false); + return !activeOrReady; + }, []); + + /* + * Checks for any components currently marqueeing + * + * @returns {Boolean} `true` if any component is marqueeing + */ + const anyRunning = useCallback(() => { + return mutableRef.current.controlled.reduce((res, component) => { + return res || (component.state === STATE.active); + }, false); + }, []); + + const doCancel = useCallback(() => { + if (mutableRef.current.isHovered || mutableRef.current.isFocused) { + return; + } + markAll(STATE.inactive); + dispatch('stop'); + }, [dispatch, markAll]); + + const cancelJob = useMemo(() => new Job(() => doCancel(), 30), [doCancel]); + + /* + * Registers `component` with a set of handlers for `start` and `stop`. + * + * @param {Object} component A component, typically a React component instance, on + * which handlers will be dispatched. + * @param {Object} handlers An object containing `start` and `stop` functions + * + * @returns {undefined} + */ + const handleRegister = useCallback((component, handlers) => { + const needStart = !allInactive() || mutableRef.current.isFocused; + + mutableRef.current.controlled.push({ + ...handlers, + state: STATE.inactive, + component + }); + + if (needStart) { + dispatch('start'); + } + }, [allInactive, dispatch]); + + /* + * Unregisters `component` for synchronization + * + * @param {Object} component A previously registered component + * + * @returns {undefined} + */ + const handleUnregister = useCallback((component) => { + let wasRunning = false; + for (let i = 0; i < mutableRef.current.controlled.length; i++) { + if (mutableRef.current.controlled[i].component === component) { + wasRunning = mutableRef.current.controlled[i].state === STATE.active; + mutableRef.current.controlled.splice(i, 1); + break; + } + } + if (wasRunning && !anyRunning()) { + dispatch('start'); + } + }, [anyRunning, dispatch]); + + /* + * Handler for the `start` context function + * + * @param {Object} component A previously registered component + * + * @returns {undefined}f + */ + const handleStart = useCallback((component) => { + cancelJob.stop(); + if (!anyRunning()) { + markAll(STATE.ready); + dispatch('start', component); + } + }, [anyRunning, cancelJob, dispatch, markAll]); + + /* + * Handler for the `cancel` context function + * + * @param {Object} component A previously registered component + * + * @returns {undefined} + */ + const handleCancel = useCallback(() => { + if (anyRunning()) { + cancelJob.start(); + } + }, [anyRunning, cancelJob]); + + /* + * Handler for the `complete` context function + * + * @param {Object} component A previously registered component + * + * @returns {undefined} + */ + const handleComplete = useCallback((component) => { + const complete = markReady(component); + if (complete) { + markAll(STATE.ready); + dispatch('start'); + } + }, [dispatch, markAll, markReady]); + + const handleEnter = useCallback(() => { + mutableRef.current.isHovered = true; + if (!anyRunning()) { + dispatch('start'); + } + cancelJob.stop(); + }, [anyRunning, cancelJob, dispatch]); + + const handleLeave = useCallback(() => { + mutableRef.current.isHovered = false; + cancelJob.start(); + }, [cancelJob]); + + /* + * Handler for the focus event + */ + const handleFocus = useCallback((ev) => { + mutableRef.current.isFocused = true; + if (!anyRunning()) { + dispatch('start'); + } + cancelJob.stop(); + forwardFocus(ev, props); + }, [anyRunning, cancelJob, dispatch, props]); + + /* + * Handler for the blur event + */ + const handleBlur = useCallback((ev) => { + mutableRef.current.isFocused = false; + if (anyRunning()) { + cancelJob.start(); + } + forwardBlur(ev, props); + }, [anyRunning, cancelJob, props]); + + useEffect(() => { + return () => { + cancelJob.stop(); + }; + }, [cancelJob]); + + const value = useMemo(() => ({cancel: handleCancel, complete: handleComplete, enter: handleEnter, leave: handleLeave, register: handleRegister, start: handleStart, unregister: handleUnregister}), + [handleCancel, handleComplete, handleEnter, handleLeave, handleRegister, handleStart, handleUnregister]); + + const provideMarqueeControllerContext = useCallback((children) => { + return ( + + {children} + + ); + }, [value]); + + return { + handleBlur, + handleFocus, + provideMarqueeControllerContext + }; +}; + +export default useMarqueeController; +export { + MarqueeControllerContext, + useMarqueeController +};