Avalonia icon indicating copy to clipboard operation
Avalonia copied to clipboard

`GradientStop.Offset` Bindings Don't Work Inside `OpacityMask` Gradients

Open colejohnson66 opened this issue 2 years ago • 22 comments

Describe the bug If you put a binding on the Offset property of GradientStop, you'd expect that updating the value of the binding would cause a redraw, but it doesn't.

To Reproduce Steps to reproduce the behavior:

  1. GradientStop with a MultiBinding (normal Binding is untested) inside an OpacityMask
  2. Update binding value
  3. Observe no effect on the gradient
  4. Force a redraw by doing something to a parent control
  5. Observe gradient changing

Expected behavior Updating a binding works

Desktop (please complete the following information):

  • OS: Windows
  • Version 0.10.14

colejohnson66 avatar Jun 03 '22 16:06 colejohnson66

https://user-images.githubusercontent.com/11381599/171906817-ee329be8-38b4-4d00-8210-0b75ea407b73.mp4

Converter:

public class OpacityFadeVisibilityConverter : IMultiValueConverter
{
    private static readonly double FADE_CHECK = 50;
    public static readonly OpacityFadeVisibilityConverter Instance = new();

    public object Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
    {
        if (parameter is not string param || values.Count != 3)
            return AvaloniaProperty.UnsetValue;

        if (values[0] is not double offset || values[1] is not double extent || values[2] is not double viewport)
            return AvaloniaProperty.UnsetValue;

        // return a double between 0.0 and 1.0 that is FADE_CHECK pixels from 0.0 or 1.0
        // shift the returned value closer to 0.0/1.0 if less than FADE_CHECK pixels are offset

        double fadeCheckAsPercent = FADE_CHECK / viewport;

        if (param == "0")
        {
            if (offset < FADE_CHECK)
                return ScaleIntoRange(offset, (0, FADE_CHECK), (0, fadeCheckAsPercent));
            return fadeCheckAsPercent;
        }

        Debug.Assert(param is "100");
        double endCheck = Math.Abs(extent - viewport - offset);
        if (endCheck < FADE_CHECK)
            return ScaleIntoRange(FADE_CHECK - endCheck, (0, FADE_CHECK), (1.0 - fadeCheckAsPercent, 1.0));
        return 1.0 - fadeCheckAsPercent;
    }

    private static double ScaleIntoRange(double value, (double, double) from, (double, double) into)
    {
        double scale = (into.Item2 - into.Item1) / (from.Item2 - from.Item1);
        return (value - from.Item1) * scale + into.Item1;
    }
}

Style Sheet:

    <Style Selector="ScrollViewer.OpacityFadeX">
        <Setter Property="Background" Value="Transparent" />
        <Setter Property="Template">
            <ControlTemplate>
                <Grid
                    ColumnDefinitions="*,Auto"
                    RowDefinitions="*,Auto">

                    <Panel
                        Grid.Row="0" Grid.Column="0"
                        Grid.RowSpan="2">
                        <Panel.OpacityMask>
                            <LinearGradientBrush StartPoint="0%,50%" EndPoint="100%,50%">
                                <LinearGradientBrush.GradientStops>
                                    <GradientStop Offset="0" Color="Transparent" />
                                    <GradientStop Color="Black">
                                        <GradientStop.Offset>
                                            <MultiBinding
                                                Converter="{x:Static conv:OpacityFadeVisibilityConverter.Instance}"
                                                ConverterParameter="0">
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Offset.X" />
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Extent.Width" />
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Viewport.Width" />
                                            </MultiBinding>
                                        </GradientStop.Offset>
                                    </GradientStop>
                                    <GradientStop Color="Black">
                                        <GradientStop.Offset>
                                            <MultiBinding
                                                Converter="{x:Static conv:OpacityFadeVisibilityConverter.Instance}"
                                                ConverterParameter="100">
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Offset.X" />
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Extent.Width" />
                                                <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Viewport.Width" />
                                            </MultiBinding>
                                        </GradientStop.Offset>
                                    </GradientStop>
                                    <GradientStop Offset="1" Color="Transparent" />
                                </LinearGradientBrush.GradientStops>
                            </LinearGradientBrush>
                        </Panel.OpacityMask>

                        <!-- actual content -->
                        <ScrollContentPresenter
                            x:Name="PART_ContentPresenter"
                            Background="{TemplateBinding Background}"
                            CanHorizontallyScroll="{TemplateBinding CanHorizontallyScroll}"
                            CanVerticallyScroll="{TemplateBinding CanVerticallyScroll}"
                            Content="{TemplateBinding Content}"
                            Extent="{TemplateBinding Extent, Mode=TwoWay}"
                            Margin="{TemplateBinding Padding}"
                            Offset="{TemplateBinding Offset, Mode=TwoWay}"
                            Viewport="{TemplateBinding Viewport, Mode=TwoWay}">
                            <ScrollContentPresenter.GestureRecognizers>
                                <ScrollGestureRecognizer
                                    CanHorizontallyScroll="{TemplateBinding CanHorizontallyScroll}"
                                    CanVerticallyScroll="{TemplateBinding CanVerticallyScroll}" />
                            </ScrollContentPresenter.GestureRecognizers>
                        </ScrollContentPresenter>
                    </Panel>

                    <!-- bottom (horizontal) scroll bar -->
                    <ScrollBar
                        x:Name="PART_HorizontalScrollBar"
                        Grid.Row="1" Grid.Column="0"
                        AllowAutoHide="False"
                        Focusable="False"
                        LargeChange="{Binding LargeChange.Width, RelativeSource={RelativeSource TemplatedParent}}"
                        Maximum="{TemplateBinding HorizontalScrollBarMaximum}"
                        Orientation="Horizontal"
                        SmallChange="{Binding SmallChange.Width, RelativeSource={RelativeSource TemplatedParent}}"
                        Value="{TemplateBinding HorizontalScrollBarValue, Mode=TwoWay}"
                        ViewportSize="{TemplateBinding HorizontalScrollBarViewportSize}"
                        Visibility="{TemplateBinding HorizontalScrollBarVisibility}" />

                    <!-- right (vertical) scroll bar -->
                    <ScrollBar
                        x:Name="PART_VerticalScrollBar"
                        Grid.Row="0" Grid.Column="1"
                        AllowAutoHide="{TemplateBinding AllowAutoHide}"
                        Focusable="False"
                        LargeChange="{Binding LargeChange.Height, RelativeSource={RelativeSource TemplatedParent}}"
                        Maximum="{TemplateBinding VerticalScrollBarMaximum}"
                        Orientation="Vertical"
                        SmallChange="{Binding SmallChange.Height, RelativeSource={RelativeSource TemplatedParent}}"
                        Value="{TemplateBinding VerticalScrollBarValue, Mode=TwoWay}"
                        ViewportSize="{TemplateBinding VerticalScrollBarViewportSize}"
                        Visibility="{TemplateBinding VerticalScrollBarVisibility}"
                        ZIndex="2" />

                    <Panel
                        x:Name="PART_ScrollBarsSeparator"
                        Grid.Row="1" Grid.Column="1"
                        Background="{DynamicResource ScrollViewerScrollBarsSeparatorBackground}" />
                </Grid>
            </ControlTemplate>
        </Setter>
    </Style>

Displayed Control:

<ScrollViewer
    Classes="OpacityFadeX"
    HorizontalScrollBarVisibility="Hidden"
    VerticalScrollBarVisibility="Disabled">
    <TextBlock Text="Really long text to test Ellipses on ScrollViewer" />
</ScrollViewer>

colejohnson66 avatar Jun 03 '22 16:06 colejohnson66

Every time the <ScrollViewer> moves, the converter is being called (breakpoints are hit). The returned value is just ignored until the outer <ScrollViewer> scrolls.

colejohnson66 avatar Jun 03 '22 16:06 colejohnson66

Can you test the same brush without using it as opacity mask? I wonder if it's the brush that doesn't update or the parent that does not handle changes in opacity mask

timunie avatar Jun 03 '22 18:06 timunie

Yep; It's because of the opacity mask. Changing it to <Panel.Background> (and the transparent stops to white) works fine.

https://user-images.githubusercontent.com/11381599/171933614-065d847f-aee9-49b6-8930-1534d8b31832.mp4

colejohnson66 avatar Jun 03 '22 19:06 colejohnson66

It gets weirder: It works in the XAML previewer in Rider, just not in our theme demo. The preview markup is literally identical to what's in the theme demo.

    <Design.PreviewWith>
        <ScrollViewer
            Classes="OpacityFadeX"
            HorizontalScrollBarVisibility="Hidden"
            VerticalScrollBarVisibility="Disabled"
            Width="100">
            <TextBlock Text="oops oops oops oops oops oops oops oops oops" />
        </ScrollViewer>
    </Design.PreviewWith>

colejohnson66 avatar Jun 03 '22 19:06 colejohnson66

Designer uses immediate rendering mode iirc.

maxkatz6 avatar Jun 03 '22 20:06 maxkatz6

Try to execute this code once in your app, it should trigger invalidation for deferred renderer:

GradientStop.OffsetProperty.Changed.Subscribe(e => ((GradientStop)e).RaiseInvalidated(EventArgs.Empty));

maxkatz6 avatar Jun 03 '22 20:06 maxkatz6

e is of type AvaloniaPropertyChangedEventArgs<double> and (GradientStop)e.Sender doesn't have a RaiseInvalidated function.

colejohnson66 avatar Jun 03 '22 20:06 colejohnson66

No, I was wrong. GradientStop doesn't implement IAffectsRender at all.

maxkatz6 avatar Jun 03 '22 20:06 maxkatz6

Linear gradient brush needs to subscribe on children updates (offset and brush) and call RaiseInvalidated inside of it. To unblock yourself it should be possible to inherit LinearGradientBrush and subscribe on gradient stops changes in your custom brush type.

maxkatz6 avatar Jun 03 '22 20:06 maxkatz6

Well, LinearGradientBrush is sealed... So I had to make a dumb copy of Avalonia's with the changes:

public class LinearGradientBrush2 : GradientBrush, ILinearGradientBrush
{
    public static readonly StyledProperty<RelativePoint> StartPointProperty =
        AvaloniaProperty.Register<LinearGradientBrush2, RelativePoint>(
            nameof(StartPoint),
            RelativePoint.TopLeft);

    public static readonly StyledProperty<RelativePoint> EndPointProperty =
        AvaloniaProperty.Register<LinearGradientBrush2, RelativePoint>(
            nameof(EndPoint),
            RelativePoint.BottomRight);

    static LinearGradientBrush2()
    {
        AffectsRender<LinearGradientBrush2>(StartPointProperty, EndPointProperty);
    }

    public LinearGradientBrush2()
    {
        GradientStop.OffsetProperty.Changed.Subscribe(e => RaiseInvalidated(e));
    }

    public RelativePoint StartPoint
    {
        get => GetValue(StartPointProperty);
        set => SetValue(StartPointProperty, value);
    }

    public RelativePoint EndPoint
    {
        get => GetValue(EndPointProperty);
        set => SetValue(EndPointProperty, value);
    }

    public override IBrush ToImmutable() =>
        new ImmutableLinearGradientBrush(GradientStops.ToImmutable(), Opacity, SpreadMethod, StartPoint, EndPoint);
}

And it didn't work. The Subscribe lambda is running; I put a breakpoint on it.

I did notice that ToImmutable() was being called whenever it actually redraws. It seems the renderer is getting an immutable copy of the brush, so it would never notice when the brush offsets change.

colejohnson66 avatar Jun 03 '22 20:06 colejohnson66

And it didn't work. The Subscribe lambda is running; I put a breakpoint on it.

You need to call RaiseInvalidated on the Brush itself. Not on the Gradient Stop, which doesn't implement IAffectRenderer.

It seems the renderer is getting an immutable copy of the brush, so it would never notice when the brush offsets change.

Depends on when exactly it's called. IIRC it's immutable only when it's passed to the render thread. And invalidation is listened on the UI thread. At least in current implementation before that PR https://github.com/AvaloniaUI/Avalonia/pull/8105

maxkatz6 avatar Jun 03 '22 20:06 maxkatz6

I am running it against the brush

colejohnson66 avatar Jun 03 '22 21:06 colejohnson66

@colejohnson66 RaiseInvalidated gets called with GradientStop as an parameter, not the brush

maxkatz6 avatar Jun 03 '22 21:06 maxkatz6

I'm not sure what you mean. I'm calling RaiseInvalidated against my own LinearGradientBrush2 object, whether I do this.RaiseInvalidated or just RaiseInvalidated. If you meant passing e into the function, changing it to this:

public LinearGradientBrush2()
{
    GradientStop.OffsetProperty.Changed.Subscribe(_ => RaiseInvalidated(EventArgs.Empty));
}

...did not fix the issue.

Stepping into the call to this.RaiseInvalidated (which is in Brush.cs) shows that Invalidated is null, so nothing is ever invoked and Avalonia is unaware of the invalidation.

colejohnson66 avatar Jun 07 '22 15:06 colejohnson66

More info: Invalidated is null only when the brush is used as an OpacityMask. Doing the change to Panel.Background from above shows an invocation into WeakEventHandlerManager.OnEvent which ultimately calls Visual.AffectsRenderInvalidated.

colejohnson66 avatar Jun 09 '22 13:06 colejohnson66

I don't know how useful this is, but the only difference in the generated XamlClosure_##.Build functions is:

Changing from Transparent to White in two places:

// inside the `GradientStop` constructors
- Color = Color.FromUInt32(16777215U)
+ Color = Color.FromUInt32(uint.MaxValue)

Changing from Panel.OpacityMask to Panel.Background:

- element1.OpacityMask = (IBrush) linearGradientBrush2_1;
+ element1.Background = (IBrush) linearGradientBrush2_1;

colejohnson66 avatar Jun 10 '22 14:06 colejohnson66

Well, 11.0-preview1 seems to have made it worse... Now, scrolling the parent control vertically doesn't seem to trigger a redraw (like it did in my video).

colejohnson66 avatar Aug 31 '22 18:08 colejohnson66

Still broken on 11.0-preview3. Invalidated is still null.

colejohnson66 avatar Nov 07 '22 20:11 colejohnson66