かずきのBlog@hatena

日本マイクロソフトに勤めています。XAML + C#の組み合わせをメインに、たまにASP.NETやJavaなどの.NET系以外のことも書いています。掲載内容は個人の見解であり、所属する企業を代表するものではありません。

Windows ストアアプリでフライアウトを出すのは奥が深い

ここではコードネームMetro スタイル アプリのことをWindows ストアアプリと記載してます。

Windows ストアアプリでは、ユーザーの思考を中断しないためか旧来のデュンッ!という音と共に出てくるダイアログや、音はしないにしても、モーダルなダイアログは出さないのが理想的だと思っています。SkyDriveのアプリでも画像を消そうとしたときには、以下のようにモーダルなダイアログではなく、フライアウトと呼ばれるポップアップみたいなもので通知するようになっています。

フォルダの作成のような、ちょっとした文字列を入力するようなケースでもフライアウトは使われています。

まぁ出すだけならポップアップ使ってごにょごにょやればいいのですが、いかんせんソフトウェアキーボードが生えてきたときとか、それに合わせて表示位置調整とかしないといけなかったりするんですよね…。デスクトップで開発してる場合は見逃しがち…。

というか、SkyDriveで気に入らないのはAppBarを出して、フォルダーの作成ボタンを押して、AppBarだけを画面の下からスワイプしてひっこめる操作をしてもフライアウトが残りっぱなしのところ。賛否両論あるかもしれないのですが、フライアウトが出てる状態で別の操作をしたのだから、お前には興味がないんだよ!ということで、潔く消えてほしい。こんな風に残りっぱなしになってほしくないと思うのは気のせいでしょうか?

まぁ、せっかく入力した値が消えてほしくないという気持ちもわからんでもないですが…。どっちを優先すべきなんだろう悩ましい。

愚痴はおいといて

というわけで、めんどくさすぎるフライアウトの表示を簡略化するユーテリティを作りました。

static class FlyoutUtils
{
    // フライアウトを表示する際の左右の画面マージン
    private static readonly int RightAndLeftMargin = 20;
    // フライアウトを表示する際の下の画面マージン
    private static readonly int BottomMargin = 20;

    /// <summary>
    /// BottomAppBar上にフラウアウトを表示するためのPopupを作成します。
    /// </summary>
    /// <typeparam name="appBar">フライアウトを表示する対象のコントロールの乗っているBottomAppBar</typeparam>
    /// <typeparam name="TTarget">上部にフライアウトを表示するターゲットの型</typeparam>
    /// <typeparam name="TFlyoutContent">フライアウト内に表示するコンテンツの型</typeparam>
    /// <typeparam name="closedAction">フライアウトが閉じる時の処理</typeparam>
    /// <param name="target">上部にフラウアウトを表示するターゲット</param>
    /// <param name="content">フライアウト内に表示するコンテンツ。WidthとHeightが明示的に指定されている必要があります。</param>
    /// <returns>フライアウト表示用のPopup</returns>
    public static Popup CreateFlyout<TTarget, TFlyoutContent>(AppBar appBar, TTarget target, TFlyoutContent content)
        where TTarget : FrameworkElement
        where TFlyoutContent : FrameworkElement
    {
        // 余白を考慮した画面幅
        content.Width = Math.Min(Window.Current.Bounds.Width - RightAndLeftMargin * 2, content.Width);
            
        var popup = new Popup
        {
            // コンテンツを子としてもつPopup
            Child = content,
            // Popup外を操作された場合に自動で閉じるようにする
            IsLightDismissEnabled = true
        };

        var pt = CalcuratePosition(target, content);
        // Popupの表示位置を設定
        Canvas.SetTop(popup, pt.Y);
        Canvas.SetLeft(popup, pt.X);

        // ソフトウェアキーボードの表示時の再計算処理を行う。
        var pane = InputPane.GetForCurrentView();
        TypedEventHandler<InputPane, InputPaneVisibilityEventArgs> inputPaneVisibilityChanged = (_, e) =>
        {
            if (e.EnsuredFocusedElementInView)
            {
                // ソフトウェアキーボードが隠れるときはフライアウトの位置をリセットする
                Canvas.SetTop(popup, pt.Y);
                Canvas.SetLeft(popup, pt.X);
                return;
            }

            // ソフトウェアキーボードが表示された時
            Canvas.SetTop(popup, pt.Y - e.OccludedRect.Height);
            Canvas.SetLeft(popup, pt.X);
        };

        // フライアウトが表示されたらソフトウェアキーボードの状態監視を行う。
        popup.Opened += (_, __) =>
        {
            pane.Showing += inputPaneVisibilityChanged;
            pane.Hiding += inputPaneVisibilityChanged;
        };

        // フライアウトが表示されなくなったタイミングでソフトウェアキーボードの状態監視を辞める。
        popup.Closed += (_, __) =>
        {
            pane.Showing -= inputPaneVisibilityChanged;
            pane.Hiding -= inputPaneVisibilityChanged;
        };

        // AppBarが閉じられたらフライアウトも閉じる
        EventHandler<object> closed = null;
        closed = (_, __) =>
        {
            popup.IsOpen = false;
            appBar.Closed -= closed;
        };
        appBar.Closed += closed;

        return popup;
    }

    private static Point CalcuratePosition<TTarget, TFlyoutContent>(TTarget target, TFlyoutContent content)
        where TTarget : FrameworkElement
        where TFlyoutContent : FrameworkElement
    {
        // targetを起点とした座標変換を行うクラスを作成
        var gt = target.TransformToVisual(null);
        // targetの左上の座標を取得
        var pt = gt.TransformPoint(new Point());
        // フライアウトを表示する左端の座標を計算
        pt.X += target.ActualWidth - content.Width - RightAndLeftMargin;
        pt.X = Math.Max(RightAndLeftMargin, pt.X);
        // フラウアウトを表示する上端の座標を計算
        pt.Y -= content.Height + BottomMargin;

        return pt;
    }
}

幅の調整とか、適当なマージンあけて表示とか、ソフトウェアキーボードが表示されたときとか考慮してます。AppBarと連動してフライアウトも消えるようにしています。使い方は、こんな感じ。

private void FugaButton_Click(object sender, RoutedEventArgs e)
{
    // フライアウトの中身(UserControlで作るっておく。WidthとHeightを明示的に指定しないといけないのがダサい…)
    var hogeView = new HogeProcessFlyout();
    // フライアウト用のpopupを作る
    var popup = FlyoutUtils.CreateFlyout(this.BottomAppBar, (Button)sender, hogeView);

    // 独自タイミングでフライアウト閉じたかったら、AppBar閉じればフライアウトも閉じます。
    // popup.IsOpen = false;でフライアウトだけ閉じることも可
    hogeView.HogeProcessFinished += (_, __) =>
    {
        this.BottomAppBar.IsOpen = false;
    };

    // フライアウトを表示
    popup.IsOpen = true;
}

これでこんな風にフライアウトが表示できます。

ソフトウェアキーボードがにょきっと生えてきても大丈夫。

ソフトウェアキーボードをひっこめても大丈夫。

考慮もれあるかな…?

NuGetでゲット

一応NuGetにも放流しておきました。
https://nuget.org/packages/WinRT.Confirm.Flyout