XAMLを使わないWPF入門41「なるべくコード少なめでGUIアプリを作る」

コンピュータ

GUIのサンプルコードを書いていると、ウィンドウやコントロールの初期化で幅や高さなどを省略できると、コードがコンパクトになるのでは無いかと思い試してみました。

目的

UIを構成するコードの定型的な部分を排除し、コード量を最小化を試みる。

方法

ウィンドウやコントロールの初期化やデフォルトパラメタを自分好みにしたファクトリーメソッドで構成された、staticなクラスライブラリを作成する。その他、画像の読み込み、D&D、キー入力、ダイアログなど、GUIアプリを構成する機能も提供する。

手の込んだ振る舞いで無ければ、staticなクラスを他のアプリに使いまわすことが出来るはず。

実行イメージ

メインルーチン

アプリケーションごとに異なる部分。

ファイル名:NoXAML41Simpls.csproj


<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

</Project>

ファイル名:AppEntry.cs


using System;
using System.Windows;

namespace NoXAML41Simples;

using System.Windows;
public class AppEntry : Application
{
    [STAThread] public static void Main() => new AppEntry().Run(new MainWindow());
}

ファイル名:MainWindow.cs


// メインウィンドウ
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using static UI;

public sealed class MainWindow : Window
{
    public MainWindow()
    {
        var tabs = new TabControl();

        this.Init("Simple NoXAML Samples").Content(tabs);

        // ボタンのサンプル
        var tab1 = UI.Tab("Button",
            Col(
                Btn("Hello", () => MessageBox.Show("Clicked!"))
            )
        ).AddTo(tabs);

        // スライダーのサンプル
        var sliderValue = UI.Lbl("50%");
        var sliderBar = UI.Bar();
        var tab2 = UI.Tab("Slider",
            Col(
                Row(Lbl("Value:"), sliderValue),
                sliderBar,
                // 0~100 のスライダー。目盛 10、スナップ有り。
                Sld(0, 100, 50, v => {
                    sliderValue.Text = $"{(int)v}%";
                    sliderBar.Width = v * 3; // 値に応じてバーの長さを変える(簡易プレビュー)
                }, tick:10, snap:true)                
            )
        ).AddTo(tabs);

        // グリッドのサンプル
        var grid = Grd("*", "*,Auto");
        var tab3 = Tab("Grid", grid).AddTo(tabs);
        // 左
        var left = Col(
            Btn("Open", () =>
            {
                var p = Dialogs.Open();
                if (p is not null) this.Title = p;
            }),
            Btn("Save", () => { Dialogs.SaveAs("untitled.txt"); })
        ).PlaceIn(grid, 0, 0);
        // 右
        var righ = Row(
            Btn("Prompt", () =>
            {
                var p = Dialogs.Prompt("タイトル", "メッセージ", "デフォルト");
                if (p is not null) this.Title = p;
            })
        ).PlaceIn(grid, 0, 1);

        // メニューバーとステータスバーのサンプル
        var statusText = Lbl();
        var panel = new DockPanel();
        var tab4 = Tab("Menu&StatusBar", panel).AddTo(tabs);
        // メニュー
        UI.MAddToTop(panel,
            UI.MiRoot("_File",
                UI.MItem(this, "_Open", Key.O, ModifierKeys.Control, () => { statusText.Text = "Open selected"; }),
                UI.MItem(this, "_Save", Key.S, ModifierKeys.Control, () => { statusText.Text = "Save selected"; }),
                UI.MSep(),
                UI.MItem("E_xit", Close)
            ),
            UI.MiRoot("_Help",
                UI.MItem("_About", () => { statusText.Text = "About selected"; })
            )
        );
        // ステータスバー
        // ここで生成して追加、TextBlockを保持
        var (_, st) = UI.SBAddToBottom(panel, "Ready");
        statusText = st;
        // ダミーテキストブロック
        var tb = Lbl("ダミー").AddTo(panel);


        // イメージのサンプル
        var img = UI.Img();
        var tab5 = Tab("Image", img).AddTo(tabs);

        // D&D
        AllowDrop = true;
        // 画像だけ受け付けて最初の1枚を表示
        Wiring.AcceptFiles(this, files =>
            {
                img.Source = BmpSrc.FromFile(files[0]);
            },
            ".png",".jpg",".jpeg",".bmp",".gif",".webp");
        
        // Ctrl+C = Copy
        Wiring.Hotkey(this, Key.C, ModifierKeys.Control,
            () =>
            {
                if (img.Source is BitmapSource bmp)
                    Clipb.SetImageFromSourceOrThrow(img.Source);
            },
            () => img.Source is not null);
    }
}

共通ライブラリ

さまざまアプリケーションで共通につかえる再利用可能なヘルパークラス。

アタッチ拡張

ファイル名:Kit\Attach.cs


// 汎用アタッチ拡張
using System.Windows;
using System.Windows.Controls;

static class Attach
{
    // 親要素のChildrenに追加
    public static T
    AddTo<T>(this T child, Panel parent) where T : UIElement
    { parent.Children.Add(child); return child; }

    // Grid(親要素)にrow,colを指定して登録
    public static T
    PlaceIn<T>(this T child, Grid parent, int row = 0, int col = 0, int rowSpan = 1, int colSpan = 1) where T : UIElement
    {
        Grid.SetRow(child, row); Grid.SetColumn(child, col);
        if (rowSpan != 1) Grid.SetRowSpan(child, rowSpan);
        if (colSpan != 1) Grid.SetColumnSpan(child, colSpan);
        parent.Children.Add(child); return child;
    }

    // 親要素のContentにセット
    public static T
    SetTo<T>(this T child, ContentControl parent) where T : UIElement
    { parent.Content = child; return child; }

    // 親要素のItemsに追加
    public static T AddTo<T>(this T child, ItemsControl parent) where T : UIElement
    { parent.Items.Add(child); return child; }
}

ビットマップソース

ファイル名:Kit\BmpSrc.cs


// ビットマップソース
using System.IO;
using System.Windows.Media.Imaging;

public static class BmpSrc
{
    // ファイルパスから読み込み(ロックしない/Freeze 済み)
    public static BitmapSource FromFile(string path, int? decodeW = null, int? decodeH = null)
    {
        using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
        return FromStream(stream, decodeW, decodeH);
    }

    // ストリームから読み込み(必要なら)
    public static BitmapSource FromStream(Stream stream, int? decodeW = null, int? decodeH = null)
    {
        var bi = new BitmapImage();
        bi.BeginInit();
        bi.CacheOption  = BitmapCacheOption.OnLoad;   // EndInit後にstreamを閉じられる
        bi.StreamSource = stream;
        if (decodeW.HasValue) bi.DecodePixelWidth  = decodeW.Value;
        if (decodeH.HasValue) bi.DecodePixelHeight = decodeH.Value;
        bi.EndInit();
        bi.Freeze();
        return bi;
    }

    // 例外を投げたくない場合用
    public static bool TryFromFile(string path, out BitmapSource? bmp, int? decodeW = null, int? decodeH = null)
    {
        try { bmp = FromFile(path, decodeW, decodeH); return true; }
        catch { bmp = null; return false; }
    }
}

クリップボード

ファイル名:Kit\Clipb.cs


// クリップボード

using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;

public static class Clipb
{
    /// クリップボードへ画像を設定します。失敗時は例外をそのままスローします。
    /// STA スレッドで呼び出してください(WPFのUIスレッドならOK)。
    public static void SetImageOrThrow(BitmapSource bmp)
    {
        if (bmp is null) throw new ArgumentNullException(nameof(bmp));
        Clipboard.SetImage(bmp); // 失敗時は例外が呼び出し元へ
    }

    /// ImageSource が BitmapSource の場合のみコピーします。そうでなければ例外。
    public static void SetImageFromSourceOrThrow(ImageSource src)
    {
        if (src is not BitmapSource bmp)
            throw new ArgumentException("ImageSource は BitmapSource である必要があります。", nameof(src));
        Clipboard.SetImage(bmp);
    }
}

ダイアログ

ファイル名:Kit\Dialogs.cs


// ダイアログ
using System.Windows;
using System.Windows.Controls;
using Microsoft.Win32;

static class Dialogs
{
    public static string? Open(string filter = "Text|*.txt;*.log;*.md|All|*.*")
    { var d = new OpenFileDialog { Filter = filter }; return d.ShowDialog() == true ? d.FileName : null; }

    public static string? SaveAs(string suggest = "untitled.txt", string filter = "Text|*.txt|All|*.*")
    { var d = new SaveFileDialog { FileName = suggest, Filter = filter }; return d.ShowDialog() == true ? d.FileName : null; }

    public static string? Prompt(string title, string message, string defaultText = "")
    {
        var owner = Application.Current?.Windows.OfType<Window>().FirstOrDefault(w => w.IsActive);

        var w = new Window {
            Title = title, Width = 420, Height = 160,
            ResizeMode = ResizeMode.NoResize,
            WindowStartupLocation = WindowStartupLocation.CenterOwner,
            Owner = owner
        };

        var label = new TextBlock { Text = message, Margin = UiDefaults.Margin };
        var tb    = new TextBox   { Text = defaultText, Margin = UiDefaults.Margin };
        var ok    = new Button    { Content="OK",    IsDefault = true,  MinWidth = 80, Margin = UiDefaults.Margin };
        var cancel= new Button    { Content="Cancel",IsCancel  = true,  MinWidth = 80, Margin = UiDefaults.Margin };

        ok.Click += (_, __) => w.DialogResult = true; // これで閉じる

        w.Content = UI.Col(label, tb, UI.Row(ok, cancel));
        var result = w.ShowDialog();
        return result == true ? tb.Text : null;
    }
}

ボーダー

ファイル名:Kit\UI.Border.cs


// ボーダー
using System.Windows;
using System.Windows.Controls;

public static partial class UI // コントロールの初期化
{
    public static Border Bar(double height=16)
        => new Border{ Height=height, Margin=new Thickness(6),
                       Background=SystemColors.HighlightBrush };
}

ボタン

ファイル名:Kit\UI.Button.cs


// ボタン
using System.Windows;
using System.Windows.Controls;

public static partial class UI   // よく使うビルダー:Row/Col/Btn/Txt
{
    // ボタン
    public static Button
    Btn(
        string text,    // 表示文字列
        Action? onClick = null    // クリック時実行するコード
    )
    {
        var b = new Button
        {
            Content = text,
            Margin = new Thickness(6),
            MinWidth = 96
        };
        if (onClick != null)
            b.Click += (_, __) => onClick();
        return b;
    }
}

グリッド

ファイル名:Kit\UI.Grid.cs


// グリッド
using System.Windows;
using System.Windows.Controls;

public static partial class UI // コントロールの初期化
{
    // グリッド
    public static Grid
    Grd(string rows = "*", string cols = "*")
    {
        var g = new Grid();
        foreach (var r in rows.Split(',')) g.RowDefinitions.Add(new RowDefinition { Height = GrdParse(r) });
        foreach (var c in cols.Split(',')) g.ColumnDefinitions.Add(new ColumnDefinition { Width = GrdParse(c) });
        return g;
    }
    // グリッド引数文字列=>オプション値
    static GridLength GrdParse(string s)
    {
        s = s.Trim();
        if (s.Equals("Auto", System.StringComparison.OrdinalIgnoreCase)) return GridLength.Auto;
        if (s.EndsWith("*")) return new GridLength(s.Length == 1 ? 1 : double.Parse(s[..^1]), GridUnitType.Star);
        return new GridLength(double.Parse(s), GridUnitType.Pixel);
    } 
}

イメージ

ファイル名:Kit\UI.Image.cs


// イメージ

using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;

public static partial class UI
{
    // Imageオブジェクトの生成
    public static Image Img(
        Stretch stretch = Stretch.Uniform,
        double? width = null,
        double? height = null,
        Thickness? margin = null,
        BitmapScalingMode scaling = BitmapScalingMode.HighQuality)
    {
        var img = new Image { Stretch = stretch };
        if (width.HasValue)  img.Width  = width.Value;
        if (height.HasValue) img.Height = height.Value;
        if (margin.HasValue) img.Margin = margin.Value;

        RenderOptions.SetBitmapScalingMode(img, scaling);
        return img;
    }
}

メニュー

ファイル名:Kit\UI.Menu.cs


// メニュー
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

public static partial class UI   // よく使うビルダー
{
    // メニューバーを作成
    public static Menu MBar(params MenuItem[] roots)
    {
        var m = new Menu();
        foreach (var r in roots) m.Items.Add(r);
        return m;
    }

    // DockPanel の上部に配置して返す
    public static Menu MAddToTop(DockPanel root, params MenuItem[] roots)
    {
        var m = MBar(roots);
        DockPanel.SetDock(m, Dock.Top);
        root.Children.Add(m);
        return m;
    }

    // ルート(サブメニューを持つ)MenuItem
    public static MenuItem MiRoot(string header, params object[] items)
    {
        var mi = new MenuItem { Header = header };
        foreach (var it in items)
        {
            switch (it)
            {
                case MenuItem m: mi.Items.Add(m); break;
                case Separator s: mi.Items.Add(s); break;
                case null: mi.Items.Add(new Separator()); break;
                default: throw new ArgumentException("Root() の items は MenuItem / Separator / null のみ可");
            }
        }
        return mi;
    }

    // クリックだけの項目
    public static MenuItem MItem(string header, Action onClick)
    {
        var mi = new MenuItem { Header = header };
        mi.Click += (_, __) => onClick();
        return mi;
    }

    // ショートカット付き項目(Window にコマンド/キーをバインドしつつ表示も揃える)
    public static MenuItem MItem(Window w, string header, Key key, ModifierKeys mods, Action onInvoke)
    {
        var cmd = new RoutedUICommand();
        w.CommandBindings.Add(new CommandBinding(cmd, (_, __) => onInvoke(), (_, e) => e.CanExecute = true));
        w.InputBindings.Add(new KeyBinding(cmd, key, mods));

        return new MenuItem
        {
            Header = header,
            Command = cmd,
            InputGestureText = MFormatGesture(key, mods) // 表示用
        };
    }

    public static Separator MSep() => new Separator();

    static string MFormatGesture(Key key, ModifierKeys mods)
    {
        var parts = new List<string>();
        if (mods.HasFlag(ModifierKeys.Control)) parts.Add("Ctrl");
        if (mods.HasFlag(ModifierKeys.Shift))   parts.Add("Shift");
        if (mods.HasFlag(ModifierKeys.Alt))     parts.Add("Alt");
        if (mods.HasFlag(ModifierKeys.Windows)) parts.Add("Win");
        parts.Add(key.ToString());
        return string.Join("+", parts);
    }
}

スライダー

ファイル名:Kit\UI.Slider.cs


// スライダー

using System.Windows;
using System.Windows.Controls;

public static partial class UI
{
    // onChanged を渡すと初期値でも一回呼び出します
    public static Slider Sld(double min = 0, double max = 100, double value = 50,
                             Action<double>? onChanged = null, double? tick = null, bool snap = false)
    {
        var s = new Slider
        {
            Minimum = min,
            Maximum = max,
            Value = value,
            Margin = new Thickness(6),
            IsMoveToPointEnabled = true,
            IsSnapToTickEnabled = snap
        };
        if (tick is double t) s.TickFrequency = t;
        if (onChanged != null)
        {
            s.ValueChanged += (_, __) => onChanged(s.Value);
            onChanged(value); // 初期反映
        }
        return s;
    }
}

スタックパネル

ファイル名:Kit\UI.StackPanel.cs


// スタックパネル

using System.Windows;
using System.Windows.Controls;

public static partial class UI
{
    // スタックパネル(横積み)
    public static StackPanel
    Row(params UIElement[] kids)
    {
        var p=new StackPanel
        {
            Orientation=Orientation.Horizontal, // 水平
            Margin=new Thickness(6)
        };
        foreach(var k in kids)
            p.Children.Add(k);
        return p;
    }
    // スタックパネル(縦積み)
    public static StackPanel
    Col(params UIElement[] kids)
    {
        var p=new StackPanel
        {
            Orientation=Orientation.Vertical,   // 垂直
            Margin=new Thickness(6)
        };
        foreach(var k in kids)
            p.Children.Add(k);
        return p;
    }

}

ステータスバー

ファイル名:Kit\UI.StatusBar.cs


// ステータスバー
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;

public static partial class UI   // よく使うビルダー
{
    /// ステータスバーを作成し、DockPanelの下部に追加します。
    /// 表示用の TextBlock も同時に作成して返します。
    public static (StatusBar bar, TextBlock text) SBAddToBottom(DockPanel root, string initial = "Ready")
    {
        var bar = new StatusBar();
        DockPanel.SetDock(bar, Dock.Bottom);

        var text = new TextBlock { Text = initial, Margin = new Thickness(6, 0, 6, 0) };
        bar.Items.Add(text);

        root.Children.Add(bar);
        return (bar, text);
    }
}

タブコントロール

ファイル名:Kit\UI.TabControl.cs


// タブコントロール
using System.Windows;
using System.Windows.Controls;

public static partial class UI
{
    public static TabItem Tab(string header, UIElement content)
        => new() { Header = header, Content = content };
}

テキストブロック

ファイル名:Kit\UI.TextBlock.cs


// テキストブロック
using System.Windows;
using System.Windows.Controls;

public static partial class UI
{
    public static TextBlock Lbl(string text="", double font=14)
        => new TextBlock{ Text=text, Margin=new Thickness(6),
                          VerticalAlignment=VerticalAlignment.Center, FontSize=font };
}

テキストボックス

ファイル名:Kit\UI.TextBox.cs


// テキストボックス
using System.Windows;
using System.Windows.Controls;


public static partial class UI   // よく使うビルダー:Row/Col/Btn/Txt
{
    // テキストボックス
    public static TextBox
    Txt(
        bool multi=true,    // 複数行
        bool wrap=false // テキストの折り返し
    )
    {
        return new TextBox
        {
            AcceptsReturn=multi,
            AcceptsTab=multi,
            TextWrapping=wrap ? TextWrapping.Wrap : TextWrapping.NoWrap,
            Margin=new Thickness(6)
        };
    }
}

UIのデフォルト値

ファイル名:Kit\UiDefaults.cs


// デフォルト値
using System.Windows;

static class UiDefaults
{
    public static double Scale = 1.0;
    public static string FontFamily = "Yu Gothic UI";
    public static double FontSize = 14;
    public static Thickness Margin = new(6);

    public static double ButtonW = 96, ButtonH = 32;
    public static double TextBoxW = 480, TextBoxH = 260;

    public static void UseCompact() { Scale = 0.9; Margin = new(4); ButtonH = 28; }
    public static void UseTouch()   { Scale = 1.3; Margin = new(10); ButtonH = 44; FontSize = 16; }
}

ウィンドウ

ファイル名:Kit\Win.cs


// Window関連
using System.Windows;

public static class Win
{
    // 初期化
    public static T
    Init<T>(
        this T w,
        string title="Demo",    // タイトル文字列
        double width=640,   // ウィンドウ幅
        double height=420,  // ウィンドウ高さ
        WindowStartupLocation loc = WindowStartupLocation.CenterScreen
    ) where T : Window
    {
        w.Title=title;
        w.Width=width;
        w.Height=height;
        w.WindowStartupLocation=loc;
        return w;
    }
    // コンテンツをセット
    public static T
    Content<T>(
        this T w,
        UIElement content
    ) where T : Window
    {
        w.Content = content;
        return w;
    }
}

イベント連携

ファイル名:Kit\Wiring.cs


using System.Windows;
using System.Windows.Input;

public static class Wiring
{

    // D&D: 指定拡張子だけ受け付ける。Unloadedで自動解除。
    public static void AcceptFiles(FrameworkElement el, Action<string[]> onFiles, params string[] exts)
    {
        el.AllowDrop = true;
        DragEventHandler? drop = null;
        drop = (_, e) =>
        {
            if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
            var files = (string[])e.Data.GetData(DataFormats.FileDrop)!;
            if (exts?.Length > 0)
                files = files.Where(f => exts.Any(x => f.EndsWith(x, StringComparison.OrdinalIgnoreCase))).ToArray();
            if (files.Length > 0) onFiles(files);
        };
        el.Drop += drop;
        el.Unloaded += (_, __) => el.Drop -= drop;
    }

    // Hotkey: Actionベースで最短。Unloadedで自動解除。
    public static void Hotkey(Window w, Key key, ModifierKeys mods, Action action, Func<bool>? canExecute = null)
    {
        var cmd = new RoutedUICommand();
        ExecutedRoutedEventHandler exec = (_, __) => action();
        CanExecuteRoutedEventHandler can = (_, e) => e.CanExecute = canExecute?.Invoke() ?? true;

        var cb = new CommandBinding(cmd, exec, can);
        var kb = new KeyBinding(cmd, key, mods);

        w.CommandBindings.Add(cb);
        w.InputBindings.Add(kb);

        RoutedEventHandler? unload = null;
        unload = (_, __) => { w.Unloaded -= unload!; w.CommandBindings.Remove(cb); w.InputBindings.Remove(kb); };
        w.Unloaded += unload;
    }
}

コメント