Hugöl Hälpingston

使用陀螺仪数据让你的 React 应用更具交互性

https://trekhleb.dev/blog/2021/gyro-web/
在 Javascript 中,您可以通过监听 deviceorientation 事件来访问设备方向数据。操作如下:

1
2
3
4
5
6
7
8
window.addEventListener("deviceorientation", handleOrientation);

function handleOrientation(event) {
const alpha = event.alpha;
const beta = event.beta;
const gamma = event.gamma;
// Do stuff...
}

Description Of Deviceorientation Data


您可以在此处访问此演示

该演示视频被 https://clideo.com/ 从 4.3MB 压缩到 93KB,令人印象深刻。

为了实现上面的效果,我们需要将其拆解为2步:

  1. 显示指定区域的隐藏内容
  2. 让“镜头”元素随设备陀螺仪传感器数据移动

步骤1.显示指定区域的隐藏内容

这个魔法是怎么发生的?

如果你熟悉 Photoshop,你就会知道有一个神奇的属性 mask,我们在 CSS 中也有这个。Mask 属性参考文档-MDN

检查 caniuse 的兼容性,结果如下。我们可以看到大约有 97% 的浏览器支持。对于移动 Android 设备,我们需要在 mask 属性前添加 -webkit 前缀才能使其生效。

Can I Use "Mask" In CSS

那么我们来测试一下。

First, we add a block with backgroud color

index.html
1
2
3
4
5
6
7
8
9
10
11
12
<style>
.example {
width: 200px;
height: 200px;
background-color: red;
text-align: center;
box-sizing: border-box;
padding-top: 30px;
}
</style>

<div class="example">a quick brown fox jumps over the lazy dog</div>
a quick brown fox jumps over the lazy dog

然后,我们在上面添加一个圆形蒙版

在添加遮罩图像之前,我们应该知道遮罩是如何与我们的背景层配合的。
在CSS的多个遮罩属性中,mask-image属性设置遮罩层图像,其属性值与background-image非常相似,可以是<url><gradient>

当我们将 mask-image 设置为 <url><gradient> 时,mask-mode 属性的值为:alpha。这意味着背景元素和遮罩层元素重叠,背景层会从遮罩层不透明的部分透过来显示出来。

1
2
3
-webkit-mask-image: url("https://r2-assets.thelynan.com/uPic/rounded-mask-59bf39.png");
-webkit-mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
a quick brown fox jumps over the lazy dog

通过实践可以看出,无论蒙版的非透明区域是什么颜色,都不会产生任何不同的蒙版效果。

iOS设备首次获取陀螺仪数据授权时,会有系统弹窗询问是否允许访问陀螺仪数据,而Android设备则是默默授权,不会弹窗。

步骤2. 使用陀螺仪传感器数据移动镜头元件

按照 移动设备方向 - 新功能 中的指导,我们将使用 useDeviceOrientation 钩子。

但隐藏的文本是如何移动的呢?

通过使用“mask-position”属性,我们可以移动遮罩层的位置。

需要注意的是,在真机运行时,iOS 设备渲染 mask-position 的过渡时,会感觉比镜头元素的 transform 过渡慢半拍,这是因为 mask-position 的渲染需要比较大的开销。 *测试机型为:iPhone 14 Pro(iOS 16.4.0)和三星 S21+(Android 13)

“mask-position” | 我可以使用… 支持 HTML5、CSS3 等表格

在这种情况下,为了使放大镜元素跟随我们的陀螺仪数据,我们需要两层:一层用于显示放大镜并使其移动,一层用于保存放大镜下方的隐藏信息。

放大镜元素跟随移动,使用 transform:translate() 来更新位置,当放大镜移动时,其下方的隐藏信息层的 mask-position 也会同时更新,这样该层的 mask 位置也一起更新,就可以得到隐藏元素始终显示在放大镜元素下方。

我为这个演示制作了一个 CodePen

编译后的 HTML 如下。

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gryro With React</title>
<style>
body {
height: 100vh;
margin: 0;
display: grid;
place-items: center;
}

.box {
width: auto;
}

.bg {
margin: 0 auto;
width: 85.06666667vw;
height: 55vw;
background-image: url("https://i.imgur.com/FZNcwts.png");
background-size: contain;
background-position: center;
background-repeat: no-repeat;
position: relative;
}
.bg .hiddenText {
font-size: 3.7vw;
transform: translateZ(0);
position: absolute;
box-sizing: border-box;
padding-top: 12.26666667vw;
left: 0;
width: 100%;
height: 100%;
text-align: center;
-webkit-mask-size: 34.13333333vw 34.13333333vw;
mask-size: 34.13333333vw 34.13333333vw;
-webkit-mask-image: url("https://i.imgur.com/nWRUuqv.png");
mask-image: url("https://i.imgur.com/nWRUuqv.png");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
transition: all ease 0.2s;
-webkit-mask-position: 25.565617vw 6.13333333vw;
mask-position: 25.565617vw 6.13333333vw;
}
.bg .lens {
position: absolute;
top: 6.13333333vw;
left: 25.33333333vw;
background-image: url("https://i.imgur.com/FOUMIQ6.png");
background-size: 100% 100%;
background-repeat: no-repeat;
width: 34.13333333vw;
height: 34.13333333vw;
transition: transform ease 0.2s;
transform: translateX(74.9%);
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module">
import React, {
useCallback,
useEffect,
useState,
} from "https://esm.sh/react@18";
import ReactDOM from "https://esm.sh/react-dom@18";
import throttle from "https://cdn.skypack.dev/[email protected]/throttle";
const useDeviceOrientation = () => {
const [error, setError] = useState(null);
const [orientation, setOrientation] = useState(null);
const onDeviceOrientation = throttle((event) => {
setOrientation({
alpha: event.alpha,
beta: event.beta,
gamma: event.gamma,
});
}, 100);
const revokeAccessAsync = async () => {
window.removeEventListener("deviceorientation", onDeviceOrientation);
setOrientation(null);
};
const requestAccessAsync = async () => {
if (!DeviceOrientationEvent) {
setError(
new Error(
"Device orientation event is not supported by your browser"
)
);
return false;
}
if (
DeviceOrientationEvent.requestPermission &&
typeof DeviceMotionEvent.requestPermission === "function"
) {
let permission;
try {
permission = await DeviceOrientationEvent.requestPermission();
} catch (err) {
setError(err);
return false;
}
if (permission !== "granted") {
setError(
new Error(
"Request to access the device orientation was rejected"
)
);
return false;
}
}
window.addEventListener("deviceorientation", onDeviceOrientation);
return true;
};
const requestAccess = useCallback(requestAccessAsync, []);
const revokeAccess = useCallback(revokeAccessAsync, []);
useEffect(() => {
return () => {
revokeAccess();
};
}, [revokeAccess]);
return {
orientation,
error,
requestAccess,
revokeAccess,
};
};
const getTransformDegree = (value, threshold = 30, max = 74.9) => {
if (value) {
const sy = value > 0 ? "" : "-";
const degree = `${Math.min(
(Math.abs(value) / threshold) * 100,
max
)}`;
return Number(`${sy}${degree}`);
}
return 0;
};
const Demo = ({ orientation }) => {
const degree = getTransformDegree(
orientation === null || orientation === void 0
? void 0
: orientation.gamma
);
const yDegree = getTransformDegree(
orientation === null || orientation === void 0
? void 0
: orientation.beta,
20,
40
);
const transform = `translate(${degree}%, ${yDegree}%)`;
const maskPosition = `${25.565617 + (degree / 100) * 34.133}vw calc(${
25.565617 + (yDegree / 100) * 34.133
}vw - 18.4vw)`;
return React.createElement(
"div",
{ className: "bg" },
React.createElement(
"div",
{
className: "hiddenText",
style: {
maskPosition,
"-webkit-mask-position": maskPosition,
},
},
React.createElement("div", null, "Hidden Text"),
React.createElement("div", null, "Hidden Text"),
React.createElement("div", null, "Hidden Text"),
React.createElement("div", null, "Hidden Text")
),
React.createElement("div", {
className: "lens",
style: {
transform,
},
})
);
};
const App = () => {
const { orientation, requestAccess, revokeAccess, error } =
useDeviceOrientation();
const errorElement = error
? React.createElement("div", { className: "error" }, error.message)
: null;
return React.createElement(
"div",
{ className: "box" },
React.createElement(
"button",
{ onClick: requestAccess },
"Request For Gyro Access"
),
errorElement,
React.createElement(Demo, { orientation: orientation })
);
};
ReactDOM.render(
React.createElement(App, null),
document.getElementById("root")
);
</script>
</body>
</html>