読者です 読者をやめる 読者になる 読者になる

kamulog

xamarin.formsのネタなど

Xamarin.Formsで任意のViewに1行Textを追加するEffect(EffectでViewの内部に何かのViewを追加する方法)

WPFでは、ControlのレイアウトはControlTemplateで何とでもいじることができますが、Xamarin.Formsではそういうことはできず、同等のことをやろうと思ったらAbsoluteLayoutなんかで包んで無理やりするしかありません。 Entryにバリデーション用の追加テキストを表示するだけで、わざわざレイアウトをネストさせるのはダルすぎるので、任意のViewに1行テキストを追加するEffectを作成しました。

リポジトリ

github.com

Nuget

AddTextの機能はプレリリースの0.1.1-preからです。

www.nuget.org

Install-Package AiForms.Effects -Pre

nuget経由で使う場合はiOSのAppDelegate.csの global::Xamarin.Forms.Forms.Init(); の後に AiForms.Effects.iOS.Effects.Init(); の記述が必要です。

概要

任意のViewに対して1行テキストを追加します。 これを使用すれば、例えばバリデーションや文字カウントなどの追加の情報を表示させることができます。 追加するテキストは位置(左上、右上、左下、右下)、文字色、フォントサイズ、Marginなどを変更できます。

パラメータ

  • On
    • EffectのOn・Off (trueでOn)
  • Text
    • 追加するテキスト
  • TextColor
    • 文字色(デフォルト Red)
  • FontSize
    • フォントサイズ(デフォルト 8)
  • Margin
    • テキストの余白(デフォルト 0,0,0,0)
    • LayoutのMarginと同じような指定でOK
  • HorizontalAlign
    • 横方向の位置(Start or End)。デフォルトはEnd。
  • VerticalAlign
    • 縦方向の位置(Start or End)。デフォルトはStart。

Xamlの書き方例

<ContentPage 
    xmlns="http://xamarin.com/schemas/2014/forms" 
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
    xmlns:ef="clr-namespace:AiForms.Effects;assembly=AiForms.Effects"
    x:Class="AiEffects.TestApp.Views.AddTextPage">
    <StackLayout Margin="4">
        <Entry HorizontalOptions="FillAndExpand" Text="{Binding Title}"
            ef:AddText.On="true" ef:AddText.TextColor="Red" 
            ef:AddText.FontSize="10" ef:AddText.Margin="4,8,4,8" 
            ef:AddText.HorizontalAlign="End"
      ef:AddText.VerticalAlign="Start" 
            ef:AddText.Text="{Binding TitleMessage}" />
    </StackLayout>
</ContentPage>

制限事項

バイスを回転させた時、Androidは位置の更新が連動せずに表示がずれます。

iOS実装

public class AddTextPlatformEffect : PlatformEffect
{
    private UILabel _textLabel;
    private NSLayoutConstraint[] _constraint;
    private Thickness _margin = 0;

    protected override void OnAttached()
    {
        _textLabel = new UILabel();
        _textLabel.LineBreakMode = UILineBreakMode.Clip;
        _textLabel.Lines = 1;
        _textLabel.TintAdjustmentMode = UIViewTintAdjustmentMode.Automatic;
        _textLabel.AdjustsFontSizeToFitWidth = true;
        _textLabel.BaselineAdjustment = UIBaselineAdjustment.AlignCenters;
        _textLabel.AdjustsLetterSpacingToFitWidth = true;

        Container.AddSubview(_textLabel);

        _textLabel.TranslatesAutoresizingMaskIntoConstraints = false;

        UpdateText();
        UpdateFontSize();
        UpdateTextColor();
        UpdateMargin();
        UpdateHorizontalAlign();
        UpdateVerticalAlign();
    }

    protected override void OnDetached()
    {
        Container.RemoveConstraints(_constraint);
        _textLabel.RemoveFromSuperview();
        _textLabel.Dispose();
        _constraint = null;
    }

    protected override void OnElementPropertyChanged(System.ComponentModel.PropertyChangedEventArgs args)
    {
        base.OnElementPropertyChanged(args);
        if (args.PropertyName == AddText.TextProperty.PropertyName) {
            UpdateText();
        }
        else if (args.PropertyName == AddText.FontSizeProperty.PropertyName) {
            UpdateFontSize();
        }
        else if (args.PropertyName == AddText.TextColorProperty.PropertyName) {
            UpdateTextColor();
        }
        else if (args.PropertyName == AddText.MarginProperty.PropertyName) {
            UpdateMargin();
        }
        else if (args.PropertyName == AddText.HorizontalAlignProperty.PropertyName) {
            UpdateHorizontalAlign();
        }
        else if (args.PropertyName == AddText.VerticalAlignProperty.PropertyName) {
            UpdateVerticalAlign();
        }
    }

    void UpdateText()
    {
        var text = AddText.GetText(Element);
        _textLabel.Text = text;
        _textLabel.Hidden = string.IsNullOrEmpty(text);

    }

    void UpdateFontSize()
    {
        _textLabel.Font = _textLabel.Font.WithSize((float)AddText.GetFontSize(Element));
    }

    void UpdateTextColor()
    {
        _textLabel.TextColor = AddText.GetTextColor(Element).ToUIColor();
    }

    void UpdateMargin()
    {
        _margin = AddText.GetMargin(Element);
    }

    void UpdateHorizontalAlign()
    {
        _textLabel.TextAlignment = AddText.GetHorizontalAlign(Element).ToNativeTextAlignment();
    }

    void UpdateVerticalAlign()
    {
        var align = AddText.GetVerticalAlign(Element);
        if (_constraint != null) {
            Container.RemoveConstraints(_constraint);
        }
        _constraint = CreateConstraint(_margin, align == TextAlignment.Start);
        Container.AddConstraints(_constraint);
    }

    NSLayoutConstraint[] CreateConstraint(Thickness margin, bool isTop = true)
    {
        var constraint = new NSLayoutConstraint[]{
            NSLayoutConstraint.Create(
                _textLabel,
                NSLayoutAttribute.Left,
                NSLayoutRelation.Equal,
                Container,
                NSLayoutAttribute.Left,
                1,
                (nfloat)margin.Left
            ),
            NSLayoutConstraint.Create(
                _textLabel,
                NSLayoutAttribute.Right,
                NSLayoutRelation.Equal,
                Container,
                NSLayoutAttribute.Right,
                1,
                -(nfloat)margin.Right
            ),
            NSLayoutConstraint.Create(
                _textLabel,
                NSLayoutAttribute.Height,
                NSLayoutRelation.Equal,
                null,
                NSLayoutAttribute.Height,
                1,
                (float)AddText.GetFontSize(Element)
            ),
            NSLayoutConstraint.Create(
                _textLabel,
                isTop ? NSLayoutAttribute.Top : NSLayoutAttribute.Bottom,
                NSLayoutRelation.Equal,
                Container,
                isTop ? NSLayoutAttribute.Top : NSLayoutAttribute.Bottom,
                1,
                isTop ? (nfloat)margin.Top : -(nfloat)margin.Bottom
            )
        };

        return constraint;
    }
}

やっていることは、以下のような感じです。

  1. 表示用のUILabelを生成
    • 1行固定、横幅に合わせてフォントサイズ調整なんかを設定
  2. ContainerにUILabelを追加
  3. 制約で位置やマージンを調整

あとはPropertyCangedと連動してUILabelの値を変えたり、縦位置が変わった場合は制約を作り直したりしています。

_textLabel.TranslatesAutoresizingMaskIntoConstraints = false;
理由は忘れましたが、制約関連でこれを設定しないとだめなようでした。

AddText.GetHorizontalAlign(Element).ToNativeTextAlignment();
この部分のToNativeTextAlignmentに関してはFormsのInternalから引っ張ってきて使っています。これは是非ともpublicにして欲しいですね。

Android実装

public class AddTextPlatformEffect : PlatformEffect
{
        private TextView _textView;
        private ContainerOnLayoutChangeListener _listener;

        protected override void OnAttached()
        {
                _textView = new TextView(Container.Context);
                _textView.SetMaxLines(1);
                _textView.SetMinLines(1);
                _textView.Ellipsize = Android.Text.TextUtils.TruncateAt.End;

                Container.AddView(_textView);

                _listener = new ContainerOnLayoutChangeListener(_textView, Element);
                Container.AddOnLayoutChangeListener(_listener);

                UpdateText();
                UpdateFontSize();
                UpdateTextColor();
                UpdateMargin();
                UpdateHorizontalAlign();
                UpdateVerticalAlign();
        }

        protected override void OnDetached()
        {
                var renderer = Container as IVisualElementRenderer;
                if (renderer?.Element != null) {    // check is disposed
                        Container.RemoveOnLayoutChangeListener(_listener);
                }

                _listener.Dispose();
                _listener = null;

                _textView.Dispose();
                _textView = null;

        }

        protected override void OnElementPropertyChanged(System.ComponentModel.PropertyChangedEventArgs args)
        {
                base.OnElementPropertyChanged(args);
                if (args.PropertyName == AddText.TextProperty.PropertyName) {
                        UpdateText();
                }
                else if (args.PropertyName == AddText.FontSizeProperty.PropertyName) {
                        UpdateFontSize();
                        Container.RequestFocus();
                }
                else if (args.PropertyName == AddText.TextColorProperty.PropertyName) {
                        UpdateTextColor();
                }
                else if (args.PropertyName == AddText.MarginProperty.PropertyName) {
                        UpdateMargin();
                        Container.RequestFocus();
                }
                else if (args.PropertyName == AddText.HorizontalAlignProperty.PropertyName) {
                        UpdateHorizontalAlign();
                }
                else if (args.PropertyName == AddText.VerticalAlignProperty.PropertyName) {
                        UpdateVerticalAlign();
                }
        }

        void UpdateText()
        {
                var text = AddText.GetText(Element);
                _textView.Text = text;
                _textView.Visibility = string.IsNullOrEmpty(text) ? ViewStates.Invisible : ViewStates.Visible;
        }

        void UpdateFontSize()
        {
                var size = (float)AddText.GetFontSize(Element);
                _textView.SetTextSize(Android.Util.ComplexUnitType.Sp, size);
        }

        void UpdateTextColor()
        {
                _textView.SetTextColor(AddText.GetTextColor(Element).ToAndroid());
        }

        void UpdateMargin()
        {
                var margin = AddText.GetMargin(Element);
                _textView.SetPadding(
                        (int)Container.Context.ToPixels(margin.Left),
                        (int)Container.Context.ToPixels(margin.Top),
                        (int)Container.Context.ToPixels(margin.Right),
                        (int)Container.Context.ToPixels(margin.Bottom)
                );
        }

        void UpdateHorizontalAlign()
        {
                _textView.Gravity = AddText.GetHorizontalAlign(Element).ToHorizontalGravityFlags();
        }

        void UpdateVerticalAlign()
        {
                Container.RequestLayout();
        }

        internal class ContainerOnLayoutChangeListener : Java.Lang.Object, Android.Views.View.IOnLayoutChangeListener
        {
                private TextView _textview;
                private Element _element;

                public ContainerOnLayoutChangeListener(TextView textview, Element element)
                {
                        _textview = textview;
                        _element = element;
                }

                // In OnLayoutChange, decide size and position of child element.
                // For some reason, in layout that was added to container, it does not work all gravity options and all layout options.
                public void OnLayoutChange(Android.Views.View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom)
                {
                        _textview.Right = v.Width;

                        var margin = AddText.GetMargin(_element);
                        var height = (int)Forms.Context.ToPixels(margin.Top) + (int)Forms.Context.ToPixels(margin.Bottom) + _textview.LineHeight;
                        var yPos = AddText.GetVerticalAlign(_element) == Xamarin.Forms.TextAlignment.Start ? 0 : v.Height - height;

                        _textview.Top = yPos;
                        _textview.Bottom = yPos + height;
                }
        }
}

Androidは以下のようなフローです。

  1. 表示用のTextViewを生成
    • 1行固定、はみ出たら省略などの設定
  2. ContainerにTextViewを追加
  3. ContainerにAddOnLayoutChangeListenerを設定
  4. 3のOnLayoutChangeでTextViewのサイズ・位置を調整

PropertyChangedでの連動はiOSと同じような感じです。

手動で位置やサイズを計算せずにLayout使えよと思うかもしれません。 私も最初考えたのがその方法です。 ですが、Containerに追加したLayoutはLayoutParameterのwidthやheight、gravityなどの設定が一切効かないんですよね。もし有効ならRelativeLayoutを使ってすごく簡単に実装できたと思います。

理由はわかりませんがLayoutが使えないのでContainerのOnLayoutChangeでがんばるしかありませんでした。

あとAndroidで回転を検知する方法が分からなかったので回転させると位置がずれてしまいます。

終わりに

この方法を使えば、テキストだけじゃなくて画像も追加することもできるので、既存のViewにワンポイントで何か挿入したい場合にこういったEffectを作っておくと便利かと思います。 iOSは制約で簡単に配置できますが、Androidは計算しないとだめなのでちょっと面倒ですけどね。