2025年1月9日木曜日

MotionのQuick Startを3つの異なる実装方法でAPEXアプリケーションに組み込んでみる

State of JavaScriptによる2024年のサーベイを見ていたところ、Graphics & Animationsの分野でFramer Motionが3位になっていました。Framer MotionはReact向けのライブラリなのでOracle APEXでは使えませんが、Motion自体はVanilla JavaScriptの環境に組み込めるため、Oracle APEXにも組み込むことができます。

実際にMotionのJavaScriptのQuick startをOracle APEXに実装してみました。Motionのサイトの以下のページに記載されています。

今回は単にMotionのQuick startをAPEXアプリケーションのページに実装するだけではなく、以下の3種類の方法で実装します。
  1. 外部ファイルに素のJavaScriptでコーディングする。
  2. 外部ファイルにapex.actionsを使ったコーディングをする。
  3. 外部ファイルを使わず、主に動的アクションで実装する。
APEXアプリケーションに組み込んだMotionのQuick startは以下のように動作します。上記の3種類とも、同じ動作になるように実装しています。


上記のAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/sample-motion.zip

これより、それぞれの実装について簡単に紹介します。

素のJavaScriptによる実装


素のJavaScriptによる実装は、ページ番号ホーム・ページに行っています。左ペインに現れるレンダリング・ビューに配置されているコンポーネントは概ねHTML要素に対応していて、APEXアプリケーションにJavaScriptのコードやCSSのクラス定義は含みません。

JavaScriptのコードは、ページ・プロパティJavaScriptファイルURLに記述している以下のファイルにまとめています。

[module,defer]#APP_FILES#js/quick-start-motion#MIN#.js

/*
* Motion - https://motion.dev/のQuick Startを実装します。
* Ref: https://motion.dev/docs/quick-start
*/
import { animate, scroll, frame, stagger } from "https://cdn.jsdelivr.net/npm/motion@11.16.0/+esm";
import * as THREE from "https://cdn.jsdelivr.net/npm/three@v0.149.0/build/three.module.js";
/*
* Create an animation - Rotate
*/
var animateRotate = null;
document.getElementById('ANIMATE_ROTATE').addEventListener('click', (event) => {
animateRotate = animate(
".box",
{
rotate: [ 0, 360 ]
},
{
duration: 1,
repeat: 3
}
);
animateRotate.then(() => {
apex.debug.info('rotate is completed.');
});
apex.debug.info('rotate is started.');
});
document.getElementById('STOP_ROTATE').addEventListener('click', (event) => {
if ( animateRotate !== null ) {
animateRotate.stop();
apex.debug.info('rotate is stopped.');
}
});
/*
* What can be animated? - Three.js
*/
var scene = new THREE.Scene({ alpha: true });
const main = document.getElementById("three-container");
var camera = new THREE.PerspectiveCamera(
25,
main.offsetWidth / main.offsetHeight,
0.1,
1000
);
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(main.offsetWidth, main.offsetHeight);
main.appendChild(renderer.domElement);
var geometry = new THREE.BoxGeometry();
var material = new THREE.MeshPhongMaterial({ color: 0x4ff0b7 });
var cube = new THREE.Mesh(geometry, material);
renderer.setClearColor(0xffffff, 0);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(2, 2, 2);
const light = new THREE.AmbientLight(0x404040); // soft white light
scene.add(light);
scene.add(directionalLight);
scene.add(cube);
camera.position.z = 5;
function rad(degrees) {
return degrees * (Math.PI / 180)
};
/**
* Create Three.js render loop using Motion's frameloop
*/
frame.render(() => renderer.render(scene, camera), true);
var animateThree = null;
document.getElementById('ANIMATE_THREE').addEventListener('click', (event) => {
animateThree = animate(
cube.rotation,
{
y: rad(360), z: rad(360)
},
{
duration: 10,
repeat: Infinity,
ease: "linear"
}
);
animateThree.then(() => {
apex.debug.info('three is completed.');
});
apex.debug.info('three is started.');
});
document.getElementById('STOP_THREE').addEventListener('click', (event) => {
if ( animateThree !== null ) {
animateThree.stop();
apex.debug.info('three is stopped.');
};
});
/**
* Customizing animations - Basic animation
*/
var animateBasic = null;
document.getElementById('ANIMATE_BASIC').addEventListener('click', (event) => {
animateBasic = animate(
".box2",
{
scale: [0.4, 1]
},
{
ease: "circInOut",
duration: 1,
repeat: 3
}
);
animateBasic.then(() => {
apex.debug.info('basic is completed.');
});
apex.debug.info('basic is started.');
});
document.getElementById('STOP_BASIC').addEventListener('click', (event) => {
if ( animateBasic !== null ) {
animateBasic.stop();
apex.debug.info('basic is stopped.');
};
});
/**
* Customizing animations - Spring
*/
var animateSpring = null;
document.getElementById('ANIMATE_SPRING').addEventListener('click', (event) => {
animateSpring = animate(
".box3",
{
rotate: [ 0, 90 ]
},
{
type: "spring",
repeat: Infinity,
repeatDelay: 0.2
}
);
animateSpring.then(() => {
apex.debug.info('spring is completed.');
});
apex.debug.info('spring is started.');
});
document.getElementById('STOP_SPRING').addEventListener('click', (event) => {
if ( animateSpring !== null ) {
animateSpring.stop();
apex.debug.info('spring is stopped.');
};
});
/**
* Customizing animations - Stagger
*/
var animateStagger = null;
document.getElementById('ANIMATE_STAGGER').addEventListener('click', (event) => {
animateStagger = animate(
".example li",
{
opacity: 1, y: [50, 0]
},
{
delay: stagger(0.05),
repeat: 3
}
);
animateStagger.then(() => {
apex.debug.info('stagger is completed.');
});
apex.debug.info('stagger is started.');
});
document.getElementById('STOP_STAGGER').addEventListener('click', (event) => {
if ( animateStagger !== null ) {
animateStagger.stop();
apex.debug.info('stagger is stopped.');
}
});

CSSのクラス定義は、ページ・プロパティCSSファイルURLに記述している以下のファイルにまとめています。

#APP_FILES#css/quick-start-motion#MIN#.css

/*
* MotionのQuick Startを実装するためのCSS
*/
.box {
width: 100px;
height: 100px;
background-color: #9911ff;
border-radius: 10px;
}
#three-container {
width: 300px;
height: 200px;
}
.box2 {
width: 100px;
height: 100px;
background-color: #4ff0b7;
border-radius: 10px;
transform: scale(0.4);
}
.box3 {
width: 100px;
height: 100px;
background-color: #ff0088;
border-radius: 10px;
}
ul.example {
list-style: none;
display: flex;
justify-content: center;
gap: 20px;
flex: 0;
margin: 0;
padding: 0;
}
ul.example li {
width: 50px;
height: 50px;
border-radius: 10px;
display: block;
background-color: #0cdcf7;
opacity: 0;
flex: 0 0 50px;
}

画面に配置されるOracle APEXのコンポーネントは、コンポーネントの単位で静的IDとしてHTML要素のID属性を設定できます。そのため、コンポーネントに設定した静的IDgetElementByIdの引数として渡すことにより、処理の対象とするHTML要素を取得できます。

以下の画面のように、ボタンに静的IDとしてANIMATE_ROTATEが設定されている場合、ボタン要素を取得するには以下を呼び出します。

document.getElementById("ANIMATE_ROTATE")


コンポーネントに含まれるHTML要素、例えばレポートに含まれるセルのようなHTML要素にアクセスする場合は、CSSクラスを設定してquerySelectorまたはquerySelectorAllを呼び出して、対象とするHTML要素を取り出します。CSSクラスといってもquerySelectorなどの引数に与えるための名前で、実際にCSSクラスを定義する必要はありません。CSSクラスを設定する場所が列CSSクラス行CSSクラス外観CSSクラス詳細CSSクラスのどこであっても、HTML要素のclass属性に含まれるため、.クラス名 (ドットで始まるクラス名)をセレクタとして指定してコンポーネントの内部にあるHTML要素にアクセスできます。

アニメーションの開始および終了は、それぞれのボタンをクリックすることで処理を呼び出します。ボタンへのイベント・リスナーの登録は、ボタン要素を取得して、addEventListenerを呼び出してコールバック・ファンクションを設定しています。
document.getElementById('ANIMATE_ROTATE').addEventListener('click', (event) => {
    animateRotate = animate(
        ".box",
        { 
            rotate: [ 0, 360 ]
        },
        {
            duration: 1,
            repeat: 3
        }
    );
    animateRotate.then(() => {
        apex.debug.info('rotate is completed.');
    });
    apex.debug.info('rotate is started.');
});
このようなコーディングは開発者への負担が大きいため、Oracle APEXのようなローコード開発プラットフォームが使われるようになったと言えますが、生成AIによるコード生成を前提とすると、より一般的な形式のコードを出力させた方が効率が良さそうです。

Oracle APEXのアプリケーションについては、コンポーネントへ静的IDやCSSクラスを設定することが多くなります。


apex.actionsを使った実装



ページ番号に、イベント・リスナーを登録する代わりに、Oracle APEXが提供しているapex.actionsを使った実装を行っています。ボタンやリンクをクリックしたときに、apex.actionsとして設定したアクションを呼び出しています。

動作は素のJavaScriptとまったく同じですが、apex.actionsを使って、コードを以下のように書き換えています。

/*
* Motion - https://motion.dev/のQuick Startを実装します。
* Ref: https://motion.dev/docs/quick-start
*/
import { animate, scroll, frame, stagger } from "https://cdn.jsdelivr.net/npm/motion@11.16.0/+esm";
import * as THREE from "https://cdn.jsdelivr.net/npm/three@v0.149.0/build/three.module.js";
/*
* What can be animated? - Three.js
*/
var scene = new THREE.Scene({ alpha: true });
const main = document.getElementById("three-container");
var camera = new THREE.PerspectiveCamera(
25,
main.offsetWidth / main.offsetHeight,
0.1,
1000
);
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(main.offsetWidth, main.offsetHeight);
main.appendChild(renderer.domElement);
var geometry = new THREE.BoxGeometry();
var material = new THREE.MeshPhongMaterial({ color: 0x4ff0b7 });
var cube = new THREE.Mesh(geometry, material);
renderer.setClearColor(0xffffff, 0);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(2, 2, 2);
const light = new THREE.AmbientLight(0x404040); // soft white light
scene.add(light);
scene.add(directionalLight);
scene.add(cube);
camera.position.z = 5;
function rad(degrees) {
return degrees * (Math.PI / 180)
};
/**
* Create Three.js render loop using Motion's frameloop
*/
frame.render(() => renderer.render(scene, camera), true);
/*
* Create an animation - Rotate
*/
var animateRotate = null;
const controlsRotate = apex.actions.createContext('controls-rotate', document.getElementById('ROTATE'));
controlsRotate.add([
{
name: "ANIMATE",
action: (event, element, args) => {
animateRotate = animate(
".box",
{
rotate: [ 0, 360 ]
},
{
duration: 1,
repeat: 3
}
);
animateRotate.then(() => {
apex.debug.info('rotate is completed.');
});
apex.debug.info('rotate is started.');
}
},
{
name: "STOP",
action: (event, element, args) => {
if ( animateRotate !== null ) {
animateRotate.stop();
apex.debug.info('rotate is stopped.');
}
}
}
]);
/*
* What can be animated? - Three.js
*/
var animateThree = null;
const controlsThree = apex.actions.createContext('controls-three', document.getElementById('THREE'));
controlsThree.add([
{
name: "ANIMATE",
action: (event, element, args) => {
animateThree = animate(
cube.rotation,
{
y: rad(360), z: rad(360)
},
{
duration: 10,
repeat: Infinity,
ease: "linear"
}
);
animateThree.then(() => {
apex.debug.info('three is completed.');
});
apex.debug.info('three is started.');
}
},
{
name: "STOP",
action: (event, element, args) => {
if ( animateThree !== null ) {
animateThree.stop();
apex.debug.info('three is stopped.');
}
}
}
]);
/**
* Customizing animations - Basic animation
*/
var animateBasic = null;
const controlsBasic = apex.actions.createContext('controls-basic', document.getElementById('BASIC'));
controlsBasic.add([
{
name: "ANIMATE",
action: (event, element, args) => {
animateBasic = animate(
".box2",
{
scale: [0.4, 1]
},
{
ease: "circInOut",
duration: 1,
repeat: 3
}
);
animateBasic.then(() => {
apex.debug.info('basic is completed.');
});
apex.debug.info('basic is started.');
}
},
{
name: "STOP",
action: (event, element, args) => {
if ( animateBasic !== null ) {
animateBasic.stop();
apex.debug.info('basic is stopped.');
}
}
}
]);
/**
* Customizing animations - Spring
*/
var animateSpring = null;
const controlsSpring = apex.actions.createContext('controls-spring', document.getElementById('SPRING'));
controlsSpring.add([
{
name: "ANIMATE",
action: (event, element, args) => {
animateSpring = animate(
".box3",
{
rotate: [ 0, 90 ]
},
{
type: "spring",
repeat: Infinity,
repeatDelay: 0.2
}
);
animateSpring.then(() => {
apex.debug.info('spring is completed.');
});
apex.debug.info('spring is started.');
}
},
{
name: "STOP",
action: (event, element, args) => {
if ( animateSpring !== null ) {
animateSpring.stop();
apex.debug.info('spring is stopped.');
}
}
}
]);
/**
* Customizing animations - Stagger
*/
var animateStagger = null;
const controlsStagger = apex.actions.createContext('controls-stagger', document.getElementById('STAGGER'));
controlsStagger.add([
{
name: "ANIMATE",
action: (event, element, args) => {
animateStagger = animate(
".example li",
{
opacity: 1, y: [50, 0]
},
{
delay: stagger(0.05),
repeat: 3
}
);
animateStagger.then(() => {
apex.debug.info('stagger is completed.');
});
apex.debug.info('stagger is started.');
}
},
{
name: "STOP",
action: (event, element, args) => {
if ( animateStagger !== null ) {
animateStagger.stop();
apex.debug.info('stagger is stopped.');
}
}
}
]);

いくつかの面でapex.actionsによる実装は、イベント・リスナーの設定よりも分かりやすい面があります。
  1. イベントの伝搬を意識しなくてよい。
  2. 呼び出される処理に名前が付いている。
  3. アクションを登録するコンテキストごとに名前空間が分かれる。
しかし、apex.actionsはボタンのクリックかリンクのクリックでの呼び出しに限定されるため、例えばmouseenterやmouseleaveといったイベントに対する処理を設定するには、イベント・リスナーを設定する必要があります。

apex.actionsを使う場合は、コンテキストの作成対象となるリージョンに静的IDを設定します。


処理を呼び出すボタンにカスタム属性data-actionを設定してアクション名を指定するか、リンクの場合はhref属性[context-id]action-name?argumentsの形式でアクション名や引数を指定します。



動的アクションによる実装



ページ番号に、Oracle APEXでの一般的な実装方法となる、動的アクションを使った実装を行なっています。

ひとつひとつのボタンに、JavaScriptコードを実行する動的アクションを設定しています。


値の設定表示非表示といった宣言的な設定だけで済む動的アクションであれば、処理内容を把握するのも難しくないと思います。しかし、JavaScriptコードがそれぞれの動的アクションに設定されている場合は、全体の処理を把握するのが難しくなる傾向があります。

動的アクションの間で共有する変数やファンクションは、ページ・プロパティJavaScriptファンクションおよびグローバル変数の宣言に記述する必要があります。
var animateRotate = null;
var animateThree = null;
var animateBasic = null;
var animateSpring = null;
var animateStagger = null;

var cube;

function rad(degrees) {
    return degrees * (Math.PI / 180)
};
Motionについては、ライブラリをJavaScriptファイルURLに指定して読み込めます。ページ中からは、グローバル変数Motionより機能を呼び出せます。

https://cdn.jsdelivr.net/npm/motion@11.16.0/dist/motion.min.js

対してThreeJSはESモジュールとして読み込む必要があり、また、従属しているライブラリもあるため、importmapの設定が必要です。
<script type="importmap">
    {
        "imports": {
            "three": "https://cdn.jsdelivr.net/npm/three@0.172.0/build/three.module.min.js",
            "three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.172.0/build/three.webgpu.min.js"
        }
    }
</script>

また、アニメーションについては、静的コンテンツのリージョンにインラインでコーディングする必要があります。

<div class="h400 u-flex u-justify-content-center u-align-items-center">
<div id="three-container"></div>
</div>
<script type="module">
import * as THREE from 'three';
var scene = new THREE.Scene({ alpha: true });
const main = document.getElementById("three-container");
var camera = new THREE.PerspectiveCamera(
25,
main.offsetWidth / main.offsetHeight,
0.1,
1000
);
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(main.offsetWidth, main.offsetHeight);
main.appendChild(renderer.domElement);
var geometry = new THREE.BoxGeometry();
var material = new THREE.MeshPhongMaterial({ color: 0x4ff0b7 });
// cube is defined in page property.
cube = new THREE.Mesh(geometry, material);
renderer.setClearColor(0xffffff, 0);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(2, 2, 2);
const light = new THREE.AmbientLight(0x404040); // soft white light
scene.add(light);
scene.add(directionalLight);
scene.add(cube);
camera.position.z = 5;
/**
* Create Three.js render loop using Motion's frameloop
*/
Motion.frame.render(() => renderer.render(scene, camera), true);
</script>

3種類の実装の紹介は以上になります。

実はOracle APEXはjQueryを使っているため、どのようなページでもjQueryがロードされ、呼び出すことができます。

https://docs.oracle.com/en/database/oracle/apex/24.1/aexjs/

The jQuery library is used by APEX and is always loaded on every page. It can be used by your code.

そのため、jQueryを使ったコーディングも可能です。今回の記事でjQueryを取り上げなかったのは、個人的にjQueryは使わないようにしているためで、jQueryを使うことに問題があるわけではありません。そのため、jQueryを使うという選択肢もあります。

今回の記事は以上になります。

Oracle APEXのアプリケーション作成の参考になれば幸いです。