Thursday, 8 April 2021

How to make the listeners always work even when I pull up or down the screen on mobile phones?

I'm developing a little library to carry out psychological experiments. You can try it live here and try the code in the following snippet.

var selfConcept = (function() {
    //privates    
    var activeListenerStepNames = [];
    var accuracyFeedbackDuration = 400;
    var blankInterval = 1500;
    var clickListenerHandler = function(e) {        
        buttonTouched = '';
        
        switch(e.target.id) {       
            case 'd-button':
                buttonTouched = 'D';
            break;
            case 'k-button':
                buttonTouched = 'K';
            break;
        }               
        
        e.data = buttonTouched;

        window.performance.mark(markName);
        window.performance.measure(measureId, generateMarkName('start'), markName);

        responseTimes.push(
            {
                'stepId': currentStep,
                'stimulus': steps[currentStep]['stimulus'],             
                'responseTime': window.performance.getEntriesByName(measureId)[0]["duration"],
                'key': e.data               
            }
        );
            
        //fix for Android
        document.getElementById("hidden-input").value = '';

        drawSetting();
        nextStep();             
    };
    var currentStep = 0;
    var fixationCrossDuration = 1000;
    var frameId;
    var keyListenerHandler = function(e) {

        if(String.fromCharCode(e.keyCode) == 'D' || String.fromCharCode(e.keyCode) == 'K') { 
            window.performance.mark(markName);
            window.performance.measure(measureId, generateMarkName('start'), markName);

            responseTimes.push(
                {
                    'stepId': currentStep,
                    'stimulus': steps[currentStep]['stimulus'],                 
                    'responseTime': window.performance.getEntriesByName(measureId)[0]["duration"],
                    'key': String.fromCharCode(e.keyCode),
                }
            );

            isAccuracyFeedbackDisplayed = false;
            removeListener(window, 'input', markName, keyListenerHandler);
            
            nextStep();
        }
    };
    var spaceListenerHandler = function(e) {            
        if(String.fromCharCode(e.keyCode) == ' '){
            e.preventDefault();
            removeListener(document, "keydown", markName, spaceListenerHandler);            
            drawSetting();
            nextStep();
        }
    }
    var isAccuracyFeedbackDisplayed = false;
    var measureId;
    var responseTimes = [];
    var steps = [];
    var groupInstruction;

    function addListener(element, event, name, eventFunction) {
        element.addEventListener(event, eventFunction);
        activeListenerStepNames.push(name);
        
        //console.log(activeListenerStepNames);
    }

    function drawSetting(text, color, background) {
        text = (text === undefined) ? '' : text;
        color = (color === undefined) ? 'black' : color;
        background = (background === undefined) ? 'white' : background;         
        
        workAreaDiv = document.getElementById("work-area");
        workAreaDiv.innerHTML = "";
        
        div = document.createElement('div');

        div.id = 'stimulus';
        div.style.color = color;
        div.innerHTML = text;
        div.style.fontSize = '280%';    

        div.style.class = 'col';            

        workAreaDiv.appendChild(div);
    }

    function fixForMobilePhones()
    {
        $('#work-area-container').removeClass('h-100').addClass('h-75');
        $('#container').append('<div class="row h-25"><div id="d-button" style="background-color: black; color: white; border-right: 1px solid white;" class="col-6 text-center"><h1>NO</h1></div><div id="k-button" style="background-color: black; color: white;" class="col-6 text-center"><h1>YES</h1></div></div>');               
        
        addListener(document.getElementById("d-button"), 'click', markName, clickListenerHandler);
        addListener(document.getElementById("k-button"), 'click', markName, clickListenerHandler);      
    }

    function generateMarkName(name) {
        return name + '-' + steps[currentStep]["type"] + '-' + currentStep;
    }

    function isMobile() {
        if(navigator.userAgent.match(/Android/i)
            || navigator.userAgent.match(/webOS/i)
            || navigator.userAgent.match(/iPhone/i)
            || navigator.userAgent.match(/iPad/i)
            || navigator.userAgent.match(/iPod/i)
            || navigator.userAgent.match(/BlackBerry/i)
            || navigator.userAgent.match(/Windows Phone/i)
        ) {
            return true;
        }
                
        return false;
    }
    
    function nextStep() {           
        var nextStep = currentStep + 1;

        if(nextStep in steps) {
            currentStep = nextStep;
            markName = generateMarkName('start');
            window.performance.mark(markName);
            //console.log("mark - markName:"+markName);
        } else {
            window.cancelAnimationFrame(frameId);
            frameId = undefined;
            
        alert('end');                                                       
        }
    }

    function randomizeSteps() {                     
        words = ["word1", "word2", "word3", "word4", "word5", "word6", "word7", "word8", "word9", "word10"];
        trials = [];        
        
        index = 0;
        for (index = 0; index < words.length; index++) {
            trials.push(
                {'id': index,'type': 'trial','stimulus': words[index]}
            );
        }
        
        instructions = [
            
        {
            'type': 'duration',
            'stimulus': '',
            'duration': blankInterval
        },
        {
            'type': 'duration',
            'stimulus': '+',
            'color': 'black',
            'duration': fixationCrossDuration
        },
        {
            'type': 'duration',
            'stimulus': '',
            'duration': blankInterval
        }];
                        
        trials = shuffleArray(trials);
                
        trialsWithBlankInterval = [];       
                
        for (itemIndex = 1; itemIndex < trials.length; itemIndex++) {           
            trialsWithBlankInterval.push(trials[itemIndex]);
            trialsWithBlankInterval.push({'type': 'duration', 'stimulus': '', 'duration': fixationCrossDuration});
        }
        
        steps = instructions.concat(trialsWithBlankInterval);
    }

    function removeListener(element, event, name, eventFunction) {
        element.removeEventListener(event, eventFunction);
        activeListenerStepNames.splice(activeListenerStepNames.indexOf(name), 1);
    }

    /* function copied from https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array */
    function shuffleArray(array) {
        for (let i = array.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [array[i], array[j]] = [array[j], array[i]];
        }
        
        return array;
    }

    function startTimer() {
        frameId = requestAnimationFrame(startTimer);
        window.performance.mark('frame');
        measureId = 'measure-' + currentStep;

        // console.log("TYPE:"+steps[currentStep]["type"]);

        if(isMobile()==true && $('#work-area-container').hasClass('h-100')) {
            fixForMobilePhones();
        }

        switch(steps[currentStep]["type"]) {
            case 'instructions':
document.getElementById("work-area").classList.remove('text-center');
                document.getElementById("work-area").classList.add('text-justify');

                document.getElementById("work-area").innerHTML = steps[currentStep]["html"];
                
                if(activeListenerStepNames.indexOf(markName) == -1) {
                    addListener(document, 'keydown', markName, spaceListenerHandler);
                }           

            break;
            
            case 'duration':
document.getElementById("work-area").classList.remove('text-justify');
                document.getElementById("work-area").classList.add('text-center');
                drawSetting(steps[currentStep]["stimulus"]);

                window.performance.measure(measureId, generateMarkName('start'), 'frame');
                performanceEntries = window.performance.getEntriesByName(measureId);

                var max = 0;
                for (var i = 0; i < performanceEntries.length; i++){
                    if (parseInt(performanceEntries[i]["duration"]) > max)
                      max = performanceEntries[i]["duration"];
                }

                if(max >= steps[currentStep]["duration"]) {
                    //console.log('step: ' + currentStep);
                    //console.log(performanceEntries[performanceEntries.length - 1]["duration"]);
                    nextStep();
                }

                break;

            case 'trial':
document.getElementById("work-area").classList.remove('text-justify');
                document.getElementById("work-area").classList.add('text-center');
                drawSetting(steps[currentStep]["stimulus"], steps[currentStep]["color"], steps[currentStep]["background"]);

                markName = generateMarkName('response');

                if(activeListenerStepNames.indexOf(markName) == -1) {                    
                    //console.log(activeListenerStepNames);
                    addListener(document, 'keydown', markName, keyListenerHandler);
                }                           

                break;
        }
    }

    //public
    return {
        init: function() {
        randomizeSteps();
                                    
            markName = generateMarkName('start');
            window.performance.mark(markName);
            console.log("INIT");
            startTimer();
        }
    }
})();

selfConcept.init();
        html, body {
            height: 100%;
            font-size: 100%;
        }
                
        .container {
            height: 100%;
        }
        
        input.transparent {
            opacity:0;
            filter:alpha(opacity=0);
        }
        
        .text-overflow {
            overflow: auto; 
            -webkit-overflow-scrolling: touch;  
        }           

        .likert .row > .col, .likert .row > [class^="col-"] {
            padding-top: .75rem;
            padding-bottom: .75rem;
            background-color: rgba(86,61,124,.15);
            border: 1px solid rgba(86,61,124,.2);
        }
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

<link href="http://178.62.83.15/test-so/bootstrap.css" rel="stylesheet"/>
<script src="http://178.62.83.15/test-so/bootstrap.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.0/jquery.min.js"></script>
<body>
      <div id="container" class="container">        
        <div id="work-area-container" class="row h-100 text-overflow">
            <div class="col my-auto">
                <div id="work-area" class="w-100 mx-auto text-justify"></div>
                <input id="hidden-input" class="transparent" type="text" readonly="readonly">
            </div>
        </div>      
        
      </div> 
</body>

Users has to categorize stimuli appeared in the middle of the screen according to two or more options (in the example: "NO" and "YES"). I have to measure in milliseconds the latency from the the moment when the stimulus appears and the keydown on the keyboard (or tap on mobile phones) of the user.

In order to achieve this goal, I used the Web APIs and in particular the Performance object. Moreover, I used Bootstrap4 to make it responsive. Everything is working fine with browsers on desktop or laptop computers: users can respond to the stimulus using the keys D (meaning "NO") and K ("YES").

The problem for which I ask you for help occurs only on mobile phones where the answer mode is based on two visible buttons: "NO" and "YES". I noticed that when I unintentionally I swipe up or down the screen (especially doing it more than once) the listeners no longer work. It's like the window loses the focus and so I have to tap twice on the buttons to make them work (I suppose the first time to gain again the focus, the second time to trigger to events) ruining the measurement of latency times.

I tried to fix this problem in the following way but it doesn't work:

document.addEventListener('touchstart', this.touchstart);
document.addEventListener('touchmove', this.touchmove);

function touchstart(e) {
    e.preventDefault();
}

function touchmove(e) {
    e.preventDefault();
}

EDIT: I tried also the following fixes inspired to the TheMindVirus's suggestion

var lastScrollPosition = 0;
window.onscroll = function(event)
{
    if((document.body.scrollTop >= 0) && (lastScrollPosition < 0))
    {
        // first try
        window.focus();

        // second try after having assigned tabindex='1' to the div "work-area-container"
       $('#work-area-container').focus();
    }
    
    lastScrollPosition = document.body.scrollTop;
}

How I can fix the problem? Thank you very much.



from How to make the listeners always work even when I pull up or down the screen on mobile phones?

No comments:

Post a Comment