kamulog

xamarin.formsのネタなど

Xamarin.Formsのマークアップ拡張でパラメータをバインディングに対応させる

ブログ記事を参考にローカライズにチャレンジしてまして

言語用リソースにこんな項目があって

<data name="Hello" xml:space="preserve">
    <value>こんにちは!{0}さん</value>
    <comment></comment>
</data>

それをマークアップ拡張を使ってXamlから読むときに

<Label Text="{l:Lang Hello,Param={Binding UserName}}" />

こんな感じで置換パラメータをBindingを使って渡せないかなと思っていたところ

というヒントをいただき、いろいろ試してみました。

とりあえずの結論

c#コード

[ContentProperty("Key")]
public class LangExtension : BindableObject, IMarkupExtension<BindingBase>
{
    public string Key { get; set; }
    public string Param { get; set; }

    public static BindableProperty InnerParamProperty =
        BindableProperty.Create(
            nameof(InnerParam),
            typeof(object),
            typeof(LangExtension),
            default(object),
         defaultBindingMode: BindingMode.OneWay
        );

    public object InnerParam {
        get { return (object)GetValue(InnerParamProperty); }
        set { SetValue(InnerParamProperty, value); }
    }

    public static BindableProperty TranslateProperty =
        BindableProperty.Create(
            nameof(Translate),
            typeof(string),
            typeof(LangExtension),
            default(string),
         defaultBindingMode: BindingMode.OneWay
        );

    public string Translate {
        get { return (string)GetValue(TranslateProperty); }
        set { SetValue(TranslateProperty, value); }
    }

    BindingBase IMarkupExtension<BindingBase>.ProvideValue(IServiceProvider serviceProvider)
    {
        //呼び出し元を取得
        var target = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
        var targetObject = (BindableObject)target.TargetObject;

        EventHandler bindingChanged = null;
        bindingChanged = (sender, e) => {
            BindingContext = targetObject.BindingContext;
            //メモリーリークが怖いのでBindingContext拾ったら即解除
            //2回目以降の変更は反映されないがよしとする
            targetObject.BindingContextChanged -= bindingChanged;
        };

        //この時点ではBindingContextが設定されてないのでイベントで待ち受ける
        targetObject.BindingContextChanged += bindingChanged;

        if (!string.IsNullOrEmpty(Param)) {
            //内部用のプロパティにバインディング
            this.SetBinding(InnerParamProperty, Param);
        }

        //パラメータが指定されてない時のためにとりあえずの翻訳実行
        UpdateTranslate();

        //自身のTranslateプロパティへのBindingを返す
        return new Binding(TranslateProperty.PropertyName, BindingMode.Default, null, null, null, this);
    }

    public object ProvideValue(IServiceProvider serviceProvider)
    {
        return (this as IMarkupExtension<BindingBase>).ProvideValue(serviceProvider);
    }

    protected override void OnPropertyChanged(string propertyName = null)
    {
        if (propertyName == InnerParamProperty.PropertyName) {
            UpdateTranslate();
        }

        base.OnPropertyChanged(propertyName);
    }

    void UpdateTranslate()
    {
        this.Translate = string.Format(Lang.ResourceManager.GetString(Key, Lang.Culture), InnerParam);
    }

}

Xaml

<Label Text="{l:Lang Hello,Param=UserName}" />

ポイント

  • BindableObjectを継承する
  • IMarkupExtensionではなくIMarkupExtension<BindingBase>を使う
  • 自身のBindingContextに呼び出し元のBindingContextをセット
    • ただし呼び出された時はBindingContextがまだセットされていないのでBindingContextChangedイベントで処理する
  • パラメータとして受け取ったものは、自身の内部用BindablePropertyとバインドさせる
  • 内部用BindablePropertyのPropertyChangedで呼び出し元に返したい値を保持するプロパティにしかるべき値をセットする
  • ProvideValueで自身の返却用プロパティへのBindingを生成して、それを返す

その他

Markupの中にBindingのMarkupは入れ込まないようにしました。 BindingExtensionはソースを見てみると中で大変なことをやってそうなので、それは経由せずに文字列として受け取って内部でBindingさせるようにしています。 まぁこの方が記述も短くて済みますしね(笑)

パラメータ2個目を試してみたんですが、そもそも言語のリソースで以下のように2個目以降を記述すると起動時に落ちるようになってしまいました。

<data name="Hello" xml:space="preserve">
    <value>こんにちは!{0}さん{1}</value>
    <comment></comment>
</data>

なので言語に関しては諦めたのですが、原理的にはParamとInnerParamを増やせば複数Bindingを組み合わせるマルチバインディングも実現可能だと思います。

参考