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

Viewed 145

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);
}
<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>

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.

1 Answers

Your event listeners and handlers in self-concept.js look fine but the two from your code snippet are wrong. There have to be ordinary function calls:

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

You can see the error in the browser console with filename and line number:

test.html:51
[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. 

Which means 'this' is not working here...

I tested your testpage with:

Galaxy S4 (Android 5):

  • opera mini (works)
  • firefox (works)
  • chrome (white screen)
  • samsung browser (white screen)

Galaxy S7 (Android 7):

  • firefox (works)
  • chrome (works)
  • samsung browser (works)

iPhone 4s (IOS 9.3.5)

  • safari (white screen)

I think it is likely that the problem is not your code but your phone or browser...

Related