【Appear】如何实现元素可见/不可见的监听

如何判定元素出现/离开屏幕可视区呢?

平时开发中,有遇到一些这样的需求

  • 判断导航栏滚出屏幕时,让导航栏变为fixed状态,反之则正常放置在文档流中
  • 当一个商品卡片/广告出现时,需要对其进行曝光埋点
  • 在瀑布流中,可以通过判定“加载更多”div的出现,来发起下一页的请求
  • 对图片们做懒加载,优化网页性能

本文结构如下:

  • IntersectionObserver的用法
  • IntersectionObserver的polyfill
  • DOM元素监听onAppear/onDisappear事件
  • DOM元素设置appear/disappear相关属性

IntersectionObserver的用法

Intersection Observer API允许你注册一个回调函数,当出现下面的行为时,会触发该回调:

  • 每当一个元素进入/退出与另一个元素(或视口)的交集时,或者当这两个元素之间的交集发生指定量的变化时
  • 首次观测该元素时会触发 不用时,还需要终止对所有目标元素可见性变化的观察,调用disconnect方法即可

用法

let options = {
  root: document.querySelector("#scrollArea"), // 观察目标元素的容器
  rootMargin: "0px", // room容器的margin,计算交集前,通过margin扩充/缩小root的高宽
  threshold: 1.0, // 交集占多少时,才执行callback。为1就是要等目标元素的每一个像素都进入root容器时才执行,默认为0,即只要目标元素刚刚1px进入root容器就触发
};

// 回调函数
let callback = (entries, observer) => {
  entries.forEach((entry) => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
    if (entry.isIntersecting) {
      let elem = entry.target;

      if (entry.intersectionRatio >= 0.75) {
        intersectionCounter++;
      }
    }
  });
};

let observer = new IntersectionObserver(callback, options);
// 目标元素
let target = document.querySelector("#listItem");
// 直到我们为观察者设置一个目标元素(即使目标当前不可见)时,回调才开始首次执行
observer.observe(target);

rootMargin不同时的效果,一图胜千言:

关于回调函数

需要注意的是回调函数是在主线程上执行的,如果它里面存在执行耗时长的任务,就会阻碍主线程做其他事情,势必会影响到页面的渲染。对于耗时长的任务,建议放到Window.requestIdleCallback(),或者放到新的宏任务中去

const handleIntersection = (entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      // 元素进入视口时执行的任务
      window.requestIdleCallback(() => {
        // 在浏览器空闲时执行的任务
        console.log('元素进入视口并浏览器处于空闲状态');
      });
    }
  });
};

const options = {
  root: null,
  rootMargin: '0px',
  threshold: 0.5,
};

const observer = new IntersectionObserver(handleIntersection, options);
const targetElement = document.getElementById('target');

observer.observe(targetElement);

神奇的问题&思路

他用IntersectionObserver来监听一个列表的滚动,当滚动慢点时候表现正常,但是滚动非常快的时候,会发现列表元素并不能被Observer来捕获,想问这是什么造成的?

一位大佬解释说,IO的主要目标是检查某个元素是否对人眼可见,根据Intersection Observer规范,其目标是提供一个简单且最佳的解决方案来推迟或预加载图像和列表、检测商务广告可见性等。但当移动滚动条的速度快于这些检查发生的速度,也就是当IO过于频繁,可能无法检测到某些可见性更改,甚至这个没有被检测到的元素,都还没有被渲染。

对于这个问题,提供了如下解决方案,即通过计算本次观测到的列表元素范围变化,知道了当前滚动的minId和maxId,就能知道列表的哪部分被检测了,从而进行业务处理。

let minId = null;
let maxId = null;
let debounceTimeout = null;

function applyChanges() {
  console.log(minId, maxId);
  const items = document.querySelectorAll('.item');
  // perform action on elements with Id between min and max
  minId = null;
  maxId = null;
}

function reportIntersection(entries) {
  clearTimeout(debounceTimeout);
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const entryId = parseInt(entry.target.id);
      if (minId === null || maxId === null) {
        minId = entryId;
        maxId = entryId;
      } else {
        minId = Math.min(minId, entryId);
        maxId = Math.max(maxId, entryId);
      }
    }
  });
  debounceTimeout = setTimeout(applyChanges, 500);
}

const container = document.querySelector('#container');
const items = document.querySelectorAll('.item');
const io = new IntersectionObserver(reportIntersection, container);
let idCounter = 0;
items.forEach(item => {
  item.setAttribute('id', idCounter++);
  io.observe(item)
});

IntersectionObserver的polyfill

通过setInterval或者监听resize、scroll、MutationObserver(用于观察 DOM 树的变化并在发生变化时触发回调函数。它可以监听 DOM 的插入、删除、属性修改、文本内容修改等变化)来触发检测,这里只贴了一些关键路径代码,想了解更多的可以网上搜搜看。

_proto._monitorIntersections = function _monitorIntersections() {
    if (!this._monitoringIntersections) {
      this._monitoringIntersections = true;

      // If a poll interval is set, use polling instead of listening to
      // resize and scroll events or DOM mutations.
      if (this.POLL_INTERVAL) {
        this._monitoringInterval = setInterval(this._checkForIntersections, this.POLL_INTERVAL);
      } else {
        addEvent(window, 'resize', this._checkForIntersections, true);
        addEvent(document, 'scroll', this._checkForIntersections, true);
        if (this.USE_MUTATION_OBSERVER && 'MutationObserver' in window) {
          this._domObserver = new MutationObserver(this._checkForIntersections);
          this._domObserver.observe(document, {
            attributes: true,
            childList: true,
            characterData: true,
            subtree: true
          });
        }
      }
    }
  }

然后通过getBoundingClientRect来计算目标元素和root容器的intersection 

const intersectionRect = computeRectIntersection(parentRect, targetRect);

 

/**
 * Returns the intersection between two rect objects.
 * @param {Object} rect1 The first rect.
 * @param {Object} rect2 The second rect.
 * @return {?Object} The intersection rect or undefined if no intersection
 *     is found.
 */
function computeRectIntersection(rect1, rect2) {
  var top = Math.max(rect1.top, rect2.top);
  var bottom = Math.min(rect1.bottom, rect2.bottom);
  var left = Math.max(rect1.left, rect2.left);
  var right = Math.min(rect1.right, rect2.right);
  var width = right - left;
  var height = bottom - top;
  return width >= 0 && height >= 0 && {
    top: top,
    bottom: bottom,
    left: left,
    right: right,
    width: width,
    height: height
  };
}

DOM元素监听onAppear/onDisappear事件

raxpollfill需要在Rax环境(简单认为Rax=React)运行,开发者在使用时,需要给元素绑定onAppear和onDisappear事件:

<div
  id="myDiv"
  onAppear={(event) => {
    console.log('appear: ', event.detail.direction);
  }}
  onDisappear={() => {
    console.log('disappear: ', event.detail.direction);
  }}
>
  hello
</div>

但是上面的代码(React的jsx)不能运行在浏览器中,还需要经过编译,然后借助react的运行时跑在浏览器中,归根结底会变为如下形式:

<div id="myDiv">
  hello
</div>
<script>
  const myDiv = document.getElementById('myDiv');
  function handlerAppear(event) {
    console.log('appear: ', event.detail.direction);
  }
  myDiv.addEventListener('appear', handlerAppear);
  
  function handlerDisAppear(event) {
    console.log('disappear: ', event.detail.direction);
  }
  myDiv.addEventListener('disappear', handlerDisAppear);
</script>

上面这段代码,这个div元素将被作为IntersectionObserver观察的目标对象,当满足了交集判断的条件,就会触发onAppear的回调,可以在回调中处理自定义的逻辑。就像onClick一样,当点击发生后,执行onClick回调的内容,那这是怎么做到的呢?

  • 拦截原型方法:需要找一个时机(addEventListener的回调触发前),为当前元素绑定IntersectionObserver事件
  • 自定义事件:当元素的Observer的callback触发后,需要抛出onAppear/onDisappear事件,执行事件回调

先通过hack DOM元素上的原型方法,当eventName为appear的事件时,给元素绑定上IntersectionObserver,即执行observerElement(this),当eventName为其他事件时,不进行特殊处理。

// hijack Node.prototype.addEventListener
const injectEventListenerHook = (events = [], Node, observerElement) => {
  let nativeAddEventListener = Node.prototype.addEventListener;

  Node.prototype.addEventListener = function (eventName, eventHandler, useCapture, doNotWatch) {
    const lowerCaseEventName = eventName && String(eventName).toLowerCase();
    const isAppearEvent = events.some((item) => (item === lowerCaseEventName));
    if (isAppearEvent) observerElement(this);

    nativeAddEventListener.call(this, eventName, eventHandler, useCapture);
  };

  return function unsetup() {
    Node.prototype.addEventListener = nativeAddEventListener;
    destroyAllIntersectionObserver();
  };
};

injectEventListenerHook(['appear', 'disappear'], window.Node, observerElement)

那当元素在root容器内的交集发生变化时,触发了Observer的回调,里面会执行dispatchEvent,如下:

function handleIntersect(entries) {
  entries.forEach((entry) => {
    const {
      target,
      boundingClientRect,
      intersectionRatio
    } = entry;
    const { currentY, beforeY } = getElementY(target, boundingClientRect);
    // is in view
    if (
      intersectionRatio > 0.01 &&
      !isTrue(target.getAttribute('data-appeared')) &&
      !appearOnce(target, 'appear')
    ) {
      target.setAttribute('data-appeared', 'true');
      target.setAttribute('data-has-appeared', 'true');
      // 主要关注这里👇
      target.dispatchEvent(createEvent('appear', {
        direction: currentY > beforeY ? 'up' : 'down'
      }));
    } else if (
      intersectionRatio === 0 &&
      isTrue(target.getAttribute('data-appeared')) &&
      !appearOnce(target, 'disappear')
    ) {
      target.setAttribute('data-appeared', 'false');
      target.setAttribute('data-has-disappeared', 'true');
      // 主要关注这里👇
      target.dispatchEvent(createEvent('disappear', {
        direction: currentY > beforeY ? 'up' : 'down'
      }));
    }

    target.setAttribute('data-before-current-y', currentY);
  });
}

当执行了dispatchEvent,发出appear事件,也就会触发appear的addEventListener里的回调,就执行自定义逻辑。

DOM元素设置appear/disappear相关属性

用到了DOM的api,即setAttribute,通过这个api把appear和disppear的信息暴露在目标元素上,比如暴露元素是否是首次曝光,元素距离root容器的y值距离等等,方便业务开发获取相应值,从而执行业务自定义逻辑。


根据提供的引用内容,你遇到的问题是在Android应用中使用相机和相册时,图片无法显示。你已经查阅了一些资料,并发现在Android 10中,存储方式发生了变化,需要在AndroidManifest.xml文件中的application标签中添加一行代码才能生效。 对于你的问题,可能是由于文件路径的问题导致图片无法显示。你提到的路径"/storage/040D-0D05/Music/recording_20240101233228.m4a"并不在"/storage/emulated/0"目录下。这可能是因为Android 10中引入了Scoped Storage的概念,应用只能访问自己的私有目录,而无法直接访问外部存储的文件。 为了解决这个问题,你可以尝试使用FileProvider来获取正确的文件路径。首先,在AndroidManifest.xml文件中的application标签内添加以下代码: ```xml <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider> ``` 然后,在res/xml目录下创建一个file_paths.xml文件,并添加以下内容: ```xml <?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <external-path name="external_files" path="." /> </paths> ``` 接下来,你可以使用FileProvider.getUriForFile()方法来获取正确的文件URI。示例代码如下: ```java File file = new File("/storage/040D-0D05/Music/recording_20240101233228.m4a"); Uri fileUri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", file); ``` 通过以上步骤,你应该能够获取到正确的文件URI,并将其用于显示图片或其他操作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值