8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Microsoft Power AppsAdvent Calendar 2024

Day 14

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

Last updated at Posted at 2024-12-13

本記事は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ギャラリーとは、世界中の有志によるカスタムコンポーネントが公開されているプラットフォームです。
image.png

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

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

作るもの

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

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

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

動作イメージ

先に出来上がりの動作をお見せします。
image.png

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

環境構築

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

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

1.1 Node.jsのインストール

Node.js公式サイトより最新版(LTS)をダウンロード、およびインストールしましょう。
img

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

node -v
npm -v

img
ダウンロード画面にNode.js、npmともにバージョンの記載があるので、そちらと見比べて相違がないか確認しましょう。

1.2 Power Platform CLIのインストール

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

npm install -g pac

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

pac

img
無事インストールが完了していれば、2行目にバージョン情報が表示されます。

1.3 Visual Studio Codeのインストール

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

VScode公式サイトよりWindows版をダウンロード、およびインストールします。
img

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

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

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

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

1. プロジェクトの作成

1.1 作業用フォルダの作成

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

mkdir AutoScrollLabel
cd AutoScrollLabel

image.png

1.2 PCFプロジェクトの作成

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

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

成功すると、以下のように作業用フォルダ配下にPCFプロジェクトが作成されます。
image.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を追加しました。

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

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

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

npm run refreshTypes

このコマンドを実行すると以下のように、ControlManifest.Input.xmlで追加したプロパティがgeneratedフォルダ内のManifestTypes.d.tsに反映されます。
image.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();
    }
}

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

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

npm run build

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

npm start

すると…
image-20241210141048139

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

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

任意の文字列を入力してみましょう。
image-20241210141501261

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

さて、続いて本題のスクロールを試してみましょう。
文字列がはみ出るようにコンポーネントの幅を縮めると…
Qiita動作確認

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


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

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

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

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

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" />
    <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>
index.ts
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)`;
    }
}

結果…

Qiita動作確認2

image-20241210221035530

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

3. 環境へのインポート

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

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

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

3.1 PCFの有効化

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

  1. Power Platform管理センターで環境を選択する
    image-20241210182029167
  2. カスタムコンポーネントをインポートしたい環境を選択する
    image-20241210182118501
  3. 画面上部のタブから設定を選択する
    また、この画面で環境URLをメモしておきましょう
    image-20241210182318493
  4. 製品から機能を開く
    image-20241210182415468
  5. 「コード コンポーネントでキャンバス アプリの公開を許可する」をオンにする
    image-20241210182504457

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

3.2 環境への接続

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

pac auth create --url [環境URL]

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

image-20241210202938999

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

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

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

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

実行には3分程度かかりますので気長に待ちましょう。
しばらくすると以下のようなメッセージが表示されて、コンポーネントのプッシュが完了します。
image-20241210211314492

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

4. アプリへの適用

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

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

  1. まずは任意のアプリのエディターを開きます。
    image-20241210212015219

  2. 続いてツリービューのコンポーネントでインポートを選択します。
    image-20241210212258108

  3. するとコンポーネントの選択画面が開きます。
    コードタブを選択すると、先ほど環境へプッシュしたコンポーネントがいるので選択してインポートしましょう!
    image-20241210212725828

  4. この状態でコンポーネント一覧を開くと、コードコンポーネントの中にインポートしたカスタムコンポーネントがいますね!
    これでカスタムコンポーネントのインポートは完了です!
    image-20241210213127601

4.2 アプリで使ってみる

あとはいつも通りアプリへコントロールを追加して、プロパティを入力するだけです!
image-20241210213419589

これで…
ee

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

おわり

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

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

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

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

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

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

それでは

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?