【Power Apps】PCFでカスタムコンポーネントを構築してみた【自由を手に入れろ】

:::note info
本記事はPower Apps Advent Calendar 2024 シリーズ2の14日目担当記事になります。
後日会社HP上の技術ブログでもアップさせていただく予定ですので、その際は記事内にリンクを追記させていただきます。
:::

# はじめに

こんにちは、reireです。

突然ですが皆さん、今のPower Appsに満足していますか？

と、初っ端から胡散臭い感じの問いかけにはなってしまいましたが、
ローコードで構築されるPower Appsは万能というわけではないのも事実です。

そこで今回は、**PowerApps Component Framework(以下、PCF)** による**カスタムコンポーネント**の構築について解説してみたいと思います。

# PCFとは

PCFとは、Power Apps上で利用できるカスタムコンポーネントの自作を行うためのフレームワークです。
PCFを活用することで、Power Apps標準のコンポーネントでは実現できない機能やUIを備えたコンポーネントを作成することができます。

例：
- Markdownを描画できるラベル
- ダブルクリックイベントを持つボタン
- アプリ内オブジェクトのドラッグドロップ操作

これらのように、独自の要件にフォーカスしたカスタムコンポーネントをアプリに適用することで、UXやパフォーマンスの向上を見込むことができます。

# PCFギャラリーについて

PCFにてカスタムコンポーネントを構築するにあたり、まずは**PCFギャラリー**の解説が必要でしょう。

[PCFギャラリー](https://pcf.gallery/)とは、世界中の有志によるカスタムコンポーネントが公開されているプラットフォームです。
![image.png](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_22.png)

今日までに様々なユーザーから、標準のPower Appsでは実現できないような様々な機能を持ったコントロールがアップされています。

自分で思い通りに開発するのも楽しいですが、PCFギャラリー内を探ってみてあなたの要件に沿うものが公開されているようであれば使ってみるのも良いでしょう！

# 作るもの

まずは何を作るかが大事ですよね。
と言いつつ私、前々から困っていたことがあったんですよね。

Power Appsのラベルコントロールって、テキストが枠をはみ出してしまった時の対処難しくないですか？？
限られた画面領域の中で、折り返しやスクロールの許可、Tooltipなどを駆使してなるべくユーザーが視認しやすいようにしていますが、いまいちスマートではないですよね。

そこで今回は、**渡された文字列が横幅を超える場合に自動でスクロールされるラベルコントロール**を構築していきたいと思います！

# 動作イメージ

先に出来上がりの動作をお見せします。
![image.png](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_21.gif)

>余談ですが、画面のベースは前回の記事で解説したファイルサイズ取得アプリの画面をベースにしています。
>ご興味のある方は是非[コチラ](https://qiita.com/reire/items/1be09b17260997845687)も併せてお読みいただければと思います。



# 環境構築

まずは環境構築から始めて行きましょう。
※2024/12/11時点のWindows11を想定しています。

## 1. ツール類のインストール

### 1.1 Node.jsのインストール

[Node.js公式サイト](https://nodejs.org/en/download/prebuilt-installer)より最新版(LTS)をダウンロード、およびインストールしましょう。
![img](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_23.png)



インストールが完了したら、PowerShellにて以下のコマンドを実行しバージョンを確認します。

```bash
node -v
npm -v
```

![img](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_24.png)
ダウンロード画面にNode.js、npmともにバージョンの記載があるので、そちらと見比べて相違がないか確認しましょう。

### 1.2 Power Platform CLIのインストール

続いて、Microsoftから提供されているPowerApps用のCLIツールである**Power Platform CLI**をインストールします。
同じくPowerShellからの実行です。

```bash
npm install -g pac
```

こちらもインストールが完了したら、以下のコマンドでバージョンを確認しましょう。

```bash
pac
```

![img](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_25.png)
無事インストールが完了していれば、2行目にバージョン情報が表示されます。

### 1.3 Visual Studio Codeのインストール

**Visual Studio Code(以下VScode)** はMicrosoftが開発したコードエディターです。
軽量で拡張機能によるカスタマイズ性も高く、今回のPCF開発との親和性も高いため利用します。

[VScode公式サイト](https://code.visualstudio.com/download)よりWindows版をダウンロード、およびインストールします。
![img](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_26.png)

以上で環境構築は完了です！

以降の作業は基本的にVScode上で行っていきます。
そのため事前にVScodeにて自分好みの拡張機能を追加してもいいでしょう。

# カスタムコンポーネントの構築

それでは構築を進めていきます。

## 1. プロジェクトの作成

### 1.1 作業用フォルダの作成

任意のディレクトリに作業用のフォルダを作成して移動しましょう。

```bash
mkdir AutoScrollLabel
cd AutoScrollLabel
```

![image.png](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_1.png)

### 1.2 PCFプロジェクトの作成

作業用フォルダ内で以下のコマンドを実行し、新しいPCFプロジェクトを作成します。
また、併せてnpmパッケージもインストールしておきます。

```bash
pac pcf init --namespace [MyNamespace] --name [MyPCFComponent] --template field --run-npm-install
```

成功すると、以下のように作業用フォルダ配下にPCFプロジェクトが作成されます。
![image.png](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_2.png)

## 2. コンポーネントの構築

プロジェクトは作成できましたので、続いてコンポーネントの構築に移ります。

### 2.1 マニフェストの編集

まずはマニフェストの編集から行っていきます。

マニフェストにはコンポーネントのメタデータやプロパティ、イベントなどの設定情報がXML形式で記載されています。

マニフェストの編集はプロジェクトフォルダ内の**ControlManifest.Input.xml**で行います。
今回は以下のように記述しました。

```ControlManifest.Input.xml
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="reirePCF" constructor="AutoScrollLabel" version="0.0.1" display-name-key="AutoScrollLabel" description-key="AutoScrollLabel description" control-type="standard" >
    <external-service-usage enabled="false">
    </external-service-usage>
    <property name="ScrollText" display-name-key="テキスト" description-key="表示する文字列を入力します" of-type="SingleLine.Text" usage="bound" required="false" />
    <resources>
      <code path="index.ts" order="1"/>
    </resources>
  </control>
</manifest>
```

実際にスクロールさせるテキストを入力するためのプロパティとしてScrollTextを追加しました。

:::note anounce
マニフェスト内に記述はありませんが、以下のプロパティについては標準で実装されているので改めての記述は不要です。

- 位置
  - X
  - Y
- サイズ
  - 幅
  - 高さ
- 表示
- 表示モード
- タブ移動順
- ヒント
:::

マニフェストの編集が完了したら以下のコマンドを実行しましょう。

```bash
npm run refreshTypes
```

このコマンドを実行すると以下のように、**ControlManifest.Input.xml**で追加したプロパティがgeneratedフォルダ内の**ManifestTypes.d.ts**に反映されます。
![image.png](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_3.png)

以上でマニフェストの編集は完了です。



### 2.2 処理の作成

いよいよ処理の実装を行っていきます。
プロジェクトフォルダ内の**Index.ts**にTypeScriptで記述していきます。
とはいっても私もそこまでTypeScriptについて経験があるわけではないので、今回はChat-GPT先生に頼らせてもらいました。

というわけで、実際に生成してもらったコードが以下になります。

```Index.ts
import {IInputs, IOutputs} from "./generated/ManifestTypes";

export class AutoScrollLabel implements ComponentFramework.StandardControl<IInputs, IOutputs> {
    private container: HTMLDivElement;
    private labelElement: HTMLDivElement;
    private scrollPos: number = 0;
    private scrollSpeed: number = 1;
    private animationFrameId: number | null = null;

    constructor() {}

    public init(
        context: ComponentFramework.Context<IInputs>,
        notifyOutputChanged: () => void,
        state: ComponentFramework.Dictionary,
        container: HTMLDivElement
    ): void {
        // コンポーネントのコンテナを初期化
        this.container = container;

        // ラベル要素を作成
        this.labelElement = document.createElement("div");
        this.labelElement.style.whiteSpace = "nowrap";
        this.labelElement.style.overflow = "hidden";
        this.labelElement.style.display = "block";
        this.labelElement.style.width = "100%";

        // スタイル設定
        this.labelElement.style.border = "1px solid #ccc";

        // 初期テキスト
        this.labelElement.innerText = context.parameters.ScrollText.raw || "Scrolling Label";

        this.container.appendChild(this.labelElement);

        // スクロールを開始
        this.startScrolling();
    }

    public updateView(context: ComponentFramework.Context<IInputs>): void {
        // テキストの更新
        const newText = context.parameters.ScrollText.raw || "";
        if (newText !== this.labelElement.innerText) {
            this.labelElement.innerText = newText;
            this.scrollPos = 0; // リセット
        }
    }

    public getOutputs(): IOutputs {
        return {};
    }

    public destroy(): void {
        // クリーンアップ
        if (this.animationFrameId) {
            cancelAnimationFrame(this.animationFrameId);
        }
    }

    private startScrolling(): void {
        const scroll = () => {
            if (this.labelElement.scrollWidth > this.labelElement.clientWidth) {
                this.scrollPos = (this.scrollPos + this.scrollSpeed) % this.labelElement.scrollWidth;
                this.labelElement.scrollLeft = this.scrollPos;
            }
            this.animationFrameId = requestAnimationFrame(scroll);
        };

        scroll();
    }
}

```

とりあえず構文エラーはなさそうですので、実際に実行していきたいと思います。

まずは以下のコマンドでビルドします。

```bash
npm run build
```

ビルドが完了したらさらに次のコマンドを実行します。

```bash
npm start
```

すると…
![image-20241210141048139](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_4.png)

このように、ブラウザ上でPCFのテスト環境が立ち上がり、構築したコンポーネントの動作確認を行うことができます！

そして画面右にはプロパティ値が入力できるようになっていて、もちろん**ScrollText**プロパティもありますね。

任意の文字列を入力してみましょう。
![image-20241210141501261](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_5.png)

入力した文字列が反映されました！

さて、続いて本題のスクロールを試してみましょう。
文字列がはみ出るようにコンポーネントの幅を縮めると…
![Qiita動作確認](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_6.gif)

このようにラベルの文字列が自動でスクロールされています！
さすがChat-GPT先生、たった一発でこれは素晴らしい！！

---

さて、自動スクロール機能は一旦実装できたのですが、ラベルコントロールとして運用するには少し物足りない部分がありそうですね。

- 文字列が中央寄せになっているので左寄せにしたい
- 文字列が初期位置に戻った後すぐにスクロールされて読みづらいので、スクロールの待機時間がほしい
- フォントやサイズを設定できるようにしたい
- PaddingやMarginなどのプロパティを設定できるようにした方がよさそう

ざっとこんなもんでしょうか。

これらについて再度Chat-GPTと詰めていきました。
(ちょくちょく手でサッと直している部分もあるので、お世辞にもきれいなコードと呼べないのはご愛嬌…)

<details><summary>
ControlManifest.Input.xml
</summary>


```ControlManifest.Input.xml
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="reirePCF" constructor="AutoScrollLabel" version="0.0.1" display-name-key="AutoScrollLabel" description-key="AutoScrollLabel description" control-type="standard" >
    <external-service-usage enabled="false">
    </external-service-usage>
    <property name="ScrollText" display-name-key="テキスト" description-key="表示する文字列を入力します" of-type="SingleLine.Text" usage="bound" required="false" />
    <property name="DelayTime" display-name-key="遅延時間" description-key="スクロールが始まるまでの時間を指定します" of-type="Whole.None" usage="bound" required="false" default-value = "3000"/>
    <property name="FontSize" display-name-key="フォントサイズ" description-key="文字の大きさを指定します" of-type="Whole.None" usage="bound" required="false" default-value = "13"/>
    <property name="Font" display-name-key="フォント" description-key="文字のフォントを指定します" of-type="Enum" usage="bound" required="false" default-value = "1">
        <value name="Arial" display-name-key="Arial">0</value>
        <value name="Open Sans" display-name-key="Open Sans">1</value>
        <value name="Verdana" display-name-key="Verdana">2</value>
        <value name="Courier New" display-name-key="Courier New">3</value>
        <value name="Georgia" display-name-key="Georgia">4</value>
    </property>
    <property name="LPadding" display-name-key="パディング(左)" description-key="左のパディングを指定します" of-type="Whole.None" usage="bound" required="false" default-value = "0"/>
    <property name="RPadding" display-name-key="パディング(右)" description-key="右のパディングを指定します" of-type="Whole.None" usage="bound" required="false" default-value = "0"/>
    <property name="TPadding" display-name-key="パディング(上)" description-key="上のパディングを指定します" of-type="Whole.None" usage="bound" required="false" default-value = "0"/>
    <property name="BPadding" display-name-key="パディング(下)" description-key="下のパディングを指定します" of-type="Whole.None" usage="bound" required="false" default-value = "0"/>
    <property name="LMargin" display-name-key="マージン(左)" description-key="左のマージンを指定します" of-type="Whole.None" usage="bound" required="false" default-value = "0"/>
    <property name="RMargin" display-name-key="マージン(右)" description-key="右のマージンを指定します" of-type="Whole.None" usage="bound" required="false" default-value = "0"/>
    <resources>
      <code path="index.ts" order="1"/>
    </resources>
  </control>
</manifest>
```

</details>

<details><summary>
index.ts
</summary>


```index.ts
import { IInputs, IOutputs } from "./generated/ManifestTypes";

export class AutoScrollLabel implements ComponentFramework.StandardControl<IInputs, IOutputs> {
    private container: HTMLDivElement;
    private labelElement: HTMLDivElement;
    private scrollPos: number = 0;
    private scrollSpeed: number = 1;
    private animationFrameId: number | null = null;
    private delayTime: number = 5000;
    private fontfamily: string[] = ['Arial', 'Open Sans', 'Verdana', 'Courier New', 'Georgia'];

    constructor() {}

    public init(
        context: ComponentFramework.Context<IInputs>,
        notifyOutputChanged: () => void,
        state: ComponentFramework.Dictionary,
        container: HTMLDivElement
    ): void {
        // コンポーネントのコンテナを初期化
        this.container = container;

        // ラベル要素を作成
        this.labelElement = document.createElement("div");
        this.labelElement.style.whiteSpace = "nowrap";
        this.labelElement.style.overflow = "hidden";
        this.labelElement.style.display = "block";
        this.labelElement.style.boxSizing = "border-box";
        this.labelElement.style.height = "100%";
        this.labelElement.style.textAlign = "left";
        this.labelElement.style.border = "none";
        this.labelElement.style.borderWidth = "0px";

        // スタイル設定
        this.styleReset(context);

        // 初期テキスト
        this.labelElement.innerText = context.parameters.ScrollText.raw || "Scrolling Label";

        this.container.appendChild(this.labelElement);

        // ディレイタイムを取得（デフォルト値: 5000ms）
        this.delayTime = context.parameters.DelayTime.raw || 5000;

        // スクロールを開始
        this.startScrolling(this.delayTime);
    }

    public updateView(context: ComponentFramework.Context<IInputs>): void {
        // テキストの更新
        const newText = context.parameters.ScrollText.raw || "";
        // ディレイタイムを更新
        const newdelayTime = context.parameters.DelayTime.raw || 5000;
        // スタイル設定
        this.styleReset(context);

        if (newText !== this.labelElement.innerText || newdelayTime !== this.delayTime) {
            this.labelElement.innerText = newText;
            this.scrollPos = 0; // リセット
            // ディレイタイムを更新
            this.delayTime = context.parameters.DelayTime.raw || 5000;

            // 前回のスクロールを停止
            if (this.animationFrameId) {
                cancelAnimationFrame(this.animationFrameId);
            }

            // 再度スクロールを開始
            this.startScrolling(this.delayTime);
        }
    }

    public getOutputs(): IOutputs {
        return {};
    }

    public destroy(): void {
        // クリーンアップ
        if (this.animationFrameId) {
            cancelAnimationFrame(this.animationFrameId);
        }
    }

    private startScrolling(delay: number): void {
        const scroll = () => {
            if (this.labelElement.scrollWidth > this.labelElement.clientWidth) {
                this.scrollPos += this.scrollSpeed;
                // テキストが右端を通過した場合に初期位置に戻る
                if (this.scrollPos >= this.labelElement.scrollWidth) {
                    this.scrollPos = 0; // 初期位置に戻す
                    this.labelElement.scrollLeft = this.scrollPos;
                    // ディレイを適用する
                    setTimeout(() => {
                        this.animationFrameId = requestAnimationFrame(scroll);
                    }, delay);
                    return; // ディレイ中は処理を一時停止
                }
                this.labelElement.scrollLeft = this.scrollPos;
            }
            this.animationFrameId = requestAnimationFrame(scroll);
        };
        scroll();
    }

    private styleReset(context: ComponentFramework.Context<IInputs>): void {
        const totalmargin = (context.parameters.LMargin.raw || 0) + (context.parameters.RMargin.raw || 0);
        // フォントサイズを更新
        this.labelElement.style.fontSize = (context.parameters.FontSize.raw || 16) + "px";
        // フォントを更新
        this.labelElement.style.fontFamily = this.fontfamily[(Number(context.parameters.Font.raw) || 1)];
        //パディングやマージンを更新
        this.labelElement.style.paddingLeft = (context.parameters.LPadding.raw || 0) + "px";
        this.labelElement.style.paddingRight = (context.parameters.RPadding.raw || 0) + "px";
        this.labelElement.style.paddingTop = (context.parameters.TPadding.raw || 0) + "px";
        this.labelElement.style.paddingBottom = (context.parameters.BPadding.raw || 0) + "px";
        this.labelElement.style.marginLeft = (context.parameters.LMargin.raw || 0) + "px";
        this.labelElement.style.marginRight = (context.parameters.RMargin.raw || 0) + "px";
        this.labelElement.style.width = `calc(100% - ${totalmargin}px)`;
    }
}
```

</details>

結果…

![Qiita動作確認2](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_7.gif)

![image-20241210221035530](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_8.png)

コンポーネントに対して両端に余白を持たせたり、フォントやサイズを変更できるようになりました！
一旦コンポーネントの構築はこんなもんで良いでしょう。

## 3. 環境へのインポート

良い感じにコンポーネントを構築することができました！

せっかく作った自作のカスタムコンポーネント、普段開発している環境内のAppsにも適用してみたいですよね。
ということで環境へのインポートを行いましょう。

環境へのインポートを行う手段はいくつかありますが、今回はMicrosoftの公式ドキュメントでも紹介されている、CLIから環境に直接プッシュする方法で進めたいと思います。
(その他にソリューションとしてzip形式でパッケージして、手でインポートを行う方法などもあります。)

### 3.1 PCFの有効化

まず大前提として、Power Platform管理センターより環境内でのPCFを有効化してあげる必要があります。

1. Power Platform管理センターで環境を選択する
   ![image-20241210182029167](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_9.png)
2. カスタムコンポーネントをインポートしたい環境を選択する
   ![image-20241210182118501](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_10.png)
3. 画面上部のタブから設定を選択する
   また、この画面で環境URLをメモしておきましょう
   ![image-20241210182318493](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_11.png)
4. 製品から機能を開く
   ![image-20241210182415468](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_12.png)
5. 「コード コンポーネントでキャンバス アプリの公開を許可する」をオンにする
   ![image-20241210182504457](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_13.png)

これによりPCFで構築したカスタムコンポーネントが環境内で有効になります。

### 3.2 環境への接続

続いてターミナルから環境へ接続します。
先ほどメモしておいた環境URLを使用します。

```bash
pac auth create --url [環境URL]
```

するとMicrosoftのサインイン画面がポップアップするので、任意の環境に接続できるアカウントでログインします。

![image-20241210202938999](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_14.png)

正常に認証されれば環境への接続は完了です。

### 3.3  環境へコンポーネントをプッシュ

では環境へコンポーネントをプッシュしていきましょう。
以下のコマンドを実行します。

```bash
pac pcf push --publisher-prefix [任意の公開元の接頭辞]
```

実行には3分程度かかりますので気長に待ちましょう。
しばらくすると以下のようなメッセージが表示されて、コンポーネントのプッシュが完了します。
![image-20241210211314492](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_15.png)

おつかれさまです、これでカスタムコンポーネントはあなたの環境へデプロイされました！

## 4. アプリへの適用

さっそくアプリで使用してみましょう！

### 4.1 コンポーネントのインポート

1. まずは任意のアプリのエディターを開きます。
   ![image-20241210212015219](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_16.png)

2. 続いてツリービューのコンポーネントでインポートを選択します。
   ![image-20241210212258108](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_17.png)

3. するとコンポーネントの選択画面が開きます。
   コードタブを選択すると、先ほど環境へプッシュしたコンポーネントがいるので選択してインポートしましょう！
   ![image-20241210212725828](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_18.png)

4. この状態でコンポーネント一覧を開くと、コードコンポーネントの中にインポートしたカスタムコンポーネントがいますね！
   これでカスタムコンポーネントのインポートは完了です！
   ![image-20241210213127601](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_19.png)

### 4.2 アプリで使ってみる

あとはいつも通りアプリへコントロールを追加して、プロパティを入力するだけです！
![image-20241210213419589](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_20.png)

これで…
![ee](https://artisan.jp.net/wp-content/uploads/2024/12/PCF_21.gif)

自作のカスタムコンポーネントをアプリに適用することができました！！

# おわり

以上、PCFを使用してカスタムコンポーネントを構築し、環境内のアプリへ適用するまでの解説でした。

一通り触ってみた感想としては以下の通りです。

- 環境構築は元々導入していたものばかりだったので思いのほか難しいことはなく、気軽に始められた
- マニフェスト周りの設定が最初はどう記述するべきかわからないので困惑する
- Microsoftの公式ドキュメントなどもあるが、基本的に何を言っているのかわからないので余計困惑する
- この辺のナレッジについては日本ではまだディープな記事が少ないため、海外の有志のブログやコミュニティ、動画を漁るのが手っ取り早い
- コーディングに関してはChat-GPTなどの生成AIを活用することで、今回のような簡単な処理であれば問題なく作成してくれた
- 果たしてこれはローコードなのか…？？

運用・保守の問題はありそうとはいえ、やはり思い通りのコンポーネントを自作してアプリに適用できる楽しさは良いですね、しばらくハマってしまいそうです！

あとはマニフェスト周りの仕様についての記事が国内にはまだまだ少ないので、その辺についても情報をアウトプットすることで国内におけるPCFの普及に貢献していきたいですね。

引き続き、Power Appsを楽しんでいきましょう！

それでは