kamulog

xamarin.formsのネタなど

Xamarin.FormsでLabelとEditorの行高・行間(LineHeight / LineSpacing)を変更するEffect

Labelで複数行の文字列を表示する場合、標準のままだと行間が詰まってて見にくいので、行間を変更するEffectを作成しました。色しらべで使用したものをベースとしてLabelの他にEditorにも対応させました。

作成したもの AlterLineHeight

github.com

サンプルはこちら

nugetにもあるよ

www.nuget.org

Install-Package AiForms.Effects

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

概要

LabelとEditorに対して、行高(LineHeight)を倍率(Multiple)で指定できるようにするEffectです。LabelとEditor以外では何もしません。

f:id:kamusoft:20161201214057g:plain:w250  f:id:kamusoft:20161201214419j:plain:w250

パラメータ

  • On
    • EffectのOn・Off(trueでEffect開始)
  • Multiple
    • フォントに対する倍率で行高を指定
    • ex)1.5 … フォントサイズの1.5倍の行高を確保する

VerticalOptionsやHeightRequestに対する挙動

  • HeightRequest未指定かつFillAndExpandじゃない時
    • 行間を広げた際に高さが不足していれば高さを広げる
    • EffectをOffで元に戻す

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"
        xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms"
        prism:ViewModelLocator.AutowireViewModel="True"
        x:Class="AiEffects.Sample.Views.AlterLineHeightPage"
        Title="AlterLineHeight">
    <StackLayout BackgroundColor="White" Spacing="4">
        <Label Text="{Binding LabelText}" VerticalOptions="Start" FontSize="12"
            ef:AlterLineHeight.On="true"
            ef:AlterLineHeight.Multiple="1.5"  />
    </StackLayout>
</ContentPage>

コード解説 iOS

PCLプロジェクトに関しては前回とほぼ同様なので省略します。

AlterLineHeightPlatformEffect.cs

public class AlterLineHeightPlatformEffect : PlatformEffect
{

    private ILineHeightEffect _effect;

    protected override void OnAttached()
    {
        if (Element is Label) {
            _effect = new LineHeightForLabel(Container, Control, Element);
        }
        else if (Element is Editor) {
            _effect = new LineHeightForTextView(Container, Control, Element);
        }

        _effect?.Update();
    }

    protected override void OnDetached()
    {
        _effect?.OnDetached();
        _effect = null;
    }

    protected override void OnElementPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e)
    {
        base.OnElementPropertyChanged(e);

        if (e.PropertyName == AlterLineHeight.MultipleProperty.PropertyName) {
            _effect?.Update();
        }
        else if (e.PropertyName == Label.FontSizeProperty.PropertyName) {
            _effect?.Update();
        }
        else if (e.PropertyName == Label.TextProperty.PropertyName && _effect is LineHeightForLabel) {
            _effect?.Update();
        }
    }
}

本当はここにNativeの実装を書いていくんですがLabelとEditorのRenderer元のViewが異なるので

public interface ILineHeightEffect
{
    void OnDetached();
    void Update();
}

こういうInterfaceを作成して実装はそっちに書いて、PlatformEffectクラスでは分岐させるだけにしました。 FontSizeが変更しても行高の再計算が必要になるのでFontSizeプロパティも監視させてます。

UILabelのLineHeight設定 LineHeightForLabel.cs

public class LineHeightForLabel : ILineHeightEffect
{
    private UIView _container;
    private UILabel _nativeLabel;
    private Label _formsLabel;
    private bool _isFixedHeight;

    public LineHeightForLabel(UIView container, UIView control, Element element)
    {
        _container = container;
        _nativeLabel = control as UILabel;
        _formsLabel = element as Label;
        //最初からHeightRequestが設定されているか
        _isFixedHeight = _formsLabel.HeightRequest >= 0d;

    }

    public void OnDetached()
    {
        _nativeLabel.AttributedText = null;
        _nativeLabel.Text = _formsLabel.Text;

        ChangeSize();

        _container = null;
        _nativeLabel = null;
        _formsLabel = null;
    }

    public void Update()
    {
        var text = _formsLabel.Text;
        if (text == null) {
            return;
        }

        var multiple = (float)AlterLineHeight.GetMultiple(_formsLabel);
        var fontSize = (float)(_formsLabel).FontSize;
        var lineSpacing = (fontSize * multiple) - fontSize;
        var pStyle = new NSMutableParagraphStyle() { LineSpacing = lineSpacing };
        var attrString = new NSMutableAttributedString(text);

        attrString.AddAttribute(UIStringAttributeKey.ParagraphStyle,
                                pStyle,
                                new NSRange(0, attrString.Length));

        _nativeLabel.AttributedText = attrString;

        ChangeSize();

    }

    void ChangeSize()
    {
        if (_formsLabel.Height < 0) {
            return;
        }

        if (NeedToChangeSize()) {
            var size = _nativeLabel.SizeThatFits(_container.Frame.Size);
            _formsLabel.HeightRequest = size.Height;
            _formsLabel.HeightRequest = -1; //再Attacheされた時の為に初期値に戻しておく
        }
        else {
            var render = Platform.GetRenderer(_formsLabel) as LabelRenderer;
            render.LayoutSubviews();
        }
    }

    bool NeedToChangeSize()
    {
        //FillAndExpandじゃなくてかつHeightRequestが未指定の時はサイズ変更
        return !_isFixedHeight &&
                !(_formsLabel.VerticalOptions.Alignment == LayoutAlignment.Fill &&
                  _formsLabel.VerticalOptions.Expands);
    }
}

核となるのはUpdateメソッドのFontSizeとパラメータの倍率を使用してLineSpacingを計算してNSMutableAttributedStringを作成してそれをLabelにセットしている箇所で、UILabelの行高設定はこれだけです。

問題は行高を変えてもLabelの高さは自動で変わってはくれないので、手動で設定する必要があっていろいろがんばってるのがChangeSizeメソッドやNeedToChangeSizeメソッドです。Forms側のHeightRequestやVerticalOptionsあたりを考慮するといろいろ大変でした。

ChangeSizeの
render.LayoutSubviews();
はFormsのRendererでVerticalTextAlignのごにょごにょをやっているのでそれを呼び出してお任せする処理です。

UITextViewのLineHeight設定 LineHeightForTextView.cs

public class LineHeightForTextView : ILineHeightEffect
{
    private UITextView _nativeTextView;
    private Editor _formsEditor;
    private LineHeightManager _manager;


    public LineHeightForTextView(UIView container, UIView control, Element element)
    {
        _nativeTextView = control as UITextView;
        _formsEditor = element as Editor;
        _manager = new LineHeightManager();
        _nativeTextView.LayoutManager.Delegate = _manager;
    }

    public void OnDetached()
    {
        _nativeTextView.LayoutManager.Delegate = null;

        _nativeTextView.Text = _nativeTextView.Text;

        _manager.Dispose();
        _manager = null;
        _nativeTextView = null;
        _formsEditor = null;
    }

    public void Update()
    {
        var multiple = AlterLineHeight.GetMultiple(_formsEditor);
        var fontSize = (float)(_formsEditor).FontSize;
        var lineSpacing = (fontSize * multiple) - fontSize;
        _manager.LineSpacing = (float)lineSpacing;

        _nativeTextView.Text = _nativeTextView.Text;
    }

}

internal class LineHeightManager : NSLayoutManagerDelegate
{
    public float LineSpacing { get; set; }

    public override nfloat LineSpacingAfterGlyphAtIndex(NSLayoutManager layoutManager, nuint glyphIndex, CoreGraphics.CGRect rect)
    {
        return LineSpacing;
    }
}

こっちはサイズは考慮する必要はなかったので楽でした。NSLayoutManagerDelegateのサブクラスを作ってそれを
_nativeTextView.LayoutManager.Delegate = _manager;
こんな感じでセットすればOKでした。あとはそのクラスに計算したLineSpacingを渡してあげればよしです。

Delegate用のクラスですが最初内部クラスにしてたんですが、それだと微動だにしなかったので多分、外側に置く必要があるのかと思います。

コード解説 Android

TextViewとEditTextの設定方法は同じなのでTextViewの方は省略します。

EditTextのLineHeight設定 LineHeightForEditText.cs

public class LineHeightForEditText : ILineHeightEffect
{
    private Android.Views.ViewGroup _container;
    private EditText _editText;
    private float _orgMultiple;
    private VisualElement _formsElement;
    private float _multiple;

    public LineHeightForEditText(Android.Views.ViewGroup container, AView control, Element element)
    {
        _container = container;
        _editText = control as EditText;
        _orgMultiple = _editText.LineSpacingMultiplier;
        _formsElement = element as VisualElement;
    }

    public void OnDetached()
    {
        var renderer = _container as IVisualElementRenderer;
        if (renderer?.Element != null) {
            _editText.SetLineSpacing(1f, _orgMultiple);
            _editText.AfterTextChanged -= _editText_AfterTextChanged;
        }
        _editText = null;
        _formsElement = null;
    }

    public void Update()
    {
        _editText.AfterTextChanged -= _editText_AfterTextChanged;
        _multiple = (float)AlterLineHeight.GetMultiple(_formsElement);

        _editText.SetLineSpacing(1f, _multiple);
        _editText.AfterTextChanged += _editText_AfterTextChanged;
    }

    void _editText_AfterTextChanged(object sender, AfterTextChangedEventArgs e)
    {
        //入力した瞬間に次の行との間隔だけが詰まってしまう問題の対策
        _editText.SetLineSpacing(1.01f, _multiple);
        _editText.SetLineSpacing(1f, _multiple);
    }
}

Updateメソッドの
_editText.SetLineSpacing(1f, _multiple);
これが全てです。

TextViewやEditTextはVerticalAlign相当が備わってるのでiOSと違ってそこは楽でした(笑)

ただEditTextに関しては既存の文の途中から入力を開始すると、その行と次の行の間隔だけ詰まってしまう現象が発生してしまって、その対策用がAfterTextChangedの部分です。なんか違う値をセットし直すと元に戻るので、それで無理矢理…。 これに関して正解があれば教えてください。

参考