DEV Community

Stipec
Stipec

Posted on

Touch001 Solving Tray Icon and minimalize UI problem on Arch Linux with C# in Avalonia

When I started to think about features for my project it came to my mind, that when the user minimalizes the application it should be hidden from the taskbar, and when I was thinking deeply then I figured out that only hiding will not be enough. The user needs to be able somehow to return from the hidden application. And the solution is to have a Tray Icon, on Windows, it is always on the taskbar on the right side, where you can find some tray icons like Network or Volume. I was curious if it will work on Linux too. Because I am using Endeavour with Plasma flavour, I knew there it should be possible somehow, but I was not sure if the Avalonia framework supports it. I googled this problem and found out there are some git projects solving this problem too. So I decided to open IDE and write some code. Firstly I started with a minimalizing problem because I thought it will be easier. When I started coding this, I easily figured out that it will not be so easy. So I googled a little bit for the first problem in official Avalonia Sources. I found out that I can do it in a way that I can register to PropertyChanged event on MainWindow, filter my specific property and then hide MainWindow. So I tried it like bellow

private void MyMainWindow_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
    if (sender is MainWindow && e.NewValue is WindowState windowState && windowState == WindowState.Minimized)
    {
        myMainWindow?.Hide();
    }
}
Enter fullscreen mode Exit fullscreen mode

I felt there should be an easier solution like this. So I googled a little bit more. I ended on google around page 10, where I find an Avalonion Gitter post on how I can try it. The post was telling about overriding the HandleWindowStateChanged method. So I tried it, and it worked properly. So then I tried it on Endeavour Linux and easily figured it out, that on Linux Task Bar Icon is not hiding after minimalize. I also saw some glitches on Windows and Linux, when you returned back to MainWindow it was not shown properly. The same post suggested calling some methods. I tried it and it worked on both platforms. And final code for Minimalize, Hide app and Hide Icon Bar is written below. Maybe it will help someone someday.

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    protected override void HandleWindowStateChanged(WindowState state)
    {
        if (state == WindowState.Minimized)
        {
            ShowInTaskbar = false;
            Hide();
        }

        if(state == WindowState.Normal)
        {
            ShowInTaskbar = true;
            this.BringIntoView();
            Activate();
            Focus();
            base.HandleWindowStateChanged(state);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Right now I do not know how to write tests for this behaviour, but at least it can be the next Touch article for it.

At this moment I still had not implemented a solution for Tray Icon. On the official Avalonia webpage, I found that Avalonia supports Tray Icon already, so I do not need to use some solution from git. But unlucky there was no documentation or Wiki for this how to use it. So I experimented.

At the end I had a code similar to this.

public partial class App : Application
{
    private MainWindow? myMainWindow;

    public override void Initialize()
    {
        AvaloniaXamlLoader.Load(this);
    }

    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            myMainWindow = new MainWindow
            {
                DataContext = new MainWindowViewModel(),
            };
            desktop.MainWindow = myMainWindow;

            RegisterTrayIcon();
        }

        base.OnFrameworkInitializationCompleted();
    }

    private void RegisterTrayIcon()
    {
        var trayIcon = new TrayIcon
        {
            IsVisible = true,
            ToolTipText = "TestToolTipText",
            Command = ReactiveCommand.Create(ShowApplication),
            Icon = new WindowIcon("/Assets/avalonia-logo.ico")
        };
    }

    private void ShowApplication()
    {
        if(myMainWindow != null)
        {
            myMainWindow.WindowState = WindowState.Normal;
            myMainWindow.Show();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And It was not working. I used also the ReactiveUI library for easy Command creation as shown below.

Command = ReactiveCommand.Create(ShowApplication),
Enter fullscreen mode Exit fullscreen mode

Where I also implemented behaviour what will happen when the user clicks on Tray Icon, for us it will show the application back.

private void ShowApplication()
{
    if(myMainWindow != null)
    {
        myMainWindow.WindowState = WindowState.Normal;
        myMainWindow.Show();
    }
}
Enter fullscreen mode Exit fullscreen mode

It failed during runtime when it was loading Icon. I figured it out that for this I cannot use .ico, but some transparent png image. So I needed to load it as Bitmap and then create WindowIcon. So I replaced this line with the code below.

Icon = new WindowIcon(new Bitmap("C:/Icons/test.png"))
Enter fullscreen mode Exit fullscreen mode

After this change, the code worked correctly. The app was hiding, when I clicked on Tray Icon it was shown back. but when I closed an application it failed during closing. Luckily exception thrown at this moment helped me to solve this problem. It was saying that I did not set Tray Icon value as Attached property. So I implemented it and yes, it works how it is expected on both platforms.

So the final code is shown below. Feel free to use and comment below the article. What do you think about this?

public partial class App : Application
{
    private MainWindow? myMainWindow;

    public override void Initialize()
    {
        AvaloniaXamlLoader.Load(this);
    }

    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            myMainWindow = new MainWindow
            {
                DataContext = new MainWindowViewModel(),
            };
            desktop.MainWindow = myMainWindow;

            RegisterTrayIcon();
        }

        base.OnFrameworkInitializationCompleted();
    }

    private void RegisterTrayIcon()
    {
        var trayIcon = new TrayIcon
        {
            IsVisible = true,
            ToolTipText = "TestToolTipText",
            Command = ReactiveCommand.Create(ShowApplication),
            Icon = new WindowIcon(new Bitmap("C:/Icons/test.png"))
        };

        var trayIcons = new TrayIcons
        {
            trayIcon
        };

        SetValue(TrayIcon.IconsProperty, trayIcons);
    }

    private void ShowApplication()
    {
        if(myMainWindow != null)
        {
            myMainWindow.WindowState = WindowState.Normal;
            myMainWindow.Show();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Oldest comments (4)

Collapse
 
axeia profile image
Axeia • Edited

Thank you very much, I doubt I could have gotten this to work if I hadn't found this post. As you mentioned the documentation for this is basically non-existent.
There is however a little mistake in the code in the end (I think?) and a little bit of room for improvement. The little mistake is the window never gets subscribed to the MyMainWindow_PropertyChanged method, so the window never gets hidden from the taskbar.

The little improvement I made is making the icon path non-static, I'm quite new to all of this so there might be a better way to go about loading the icon still but this should works as long as you are working within a Namespace and your project has a /Assets/icon.png in place.

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using ReactiveUI;
using System;
using System.Reflection;

namespace PcPunditCrawlerGUI
{
    public partial class App : Application
    {
        private MainWindow? myMainWindow;
        private void MyMainWindow_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
        {
            if (sender is MainWindow && e.NewValue is WindowState windowState && windowState == WindowState.Minimized)
            {
                myMainWindow?.Hide();
            }
        }

        public override void Initialize()
        {
            AvaloniaXamlLoader.Load(this);
        }

        public override void OnFrameworkInitializationCompleted()
        {
            if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
            {
                myMainWindow = new MainWindow
                {
                    DataContext = new MainWindowViewModel(),
                };
                desktop.MainWindow = myMainWindow;
                myMainWindow.PropertyChanged += MyMainWindow_PropertyChanged;

                RegisterTrayIcon();
            }

            base.OnFrameworkInitializationCompleted();
        }

        private void RegisterTrayIcon()
        {
            var bitmap = new Bitmap(AvaloniaLocator.Current?.GetService<IAssetLoader>()?.Open(
                new Uri($"avares://{Assembly.GetExecutingAssembly().GetName().Name}/Assets/icon.png"))
            );
            var trayIcon = new TrayIcon
            {
                IsVisible = true,
                ToolTipText = "TestToolTipText",
                Command = ReactiveCommand.Create(ShowApplication),
                Icon = new WindowIcon(bitmap)
            };

            var trayIcons = new TrayIcons
        {
            trayIcon
        };

            SetValue(TrayIcon.IconsProperty, trayIcons);
        }

        private void ShowApplication()
        {
            if (myMainWindow != null)
            {
                myMainWindow.WindowState = WindowState.Normal;
                myMainWindow.Show();
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
axeia profile image
Axeia

I figured out how to do this the MVVM (sort of) way as well - mainly thanks to looking at the code of WalletWasabi (github).

My App.axaml:

<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="using:PcPunditCrawlerGUI"
             x:Class="PcPunditCrawlerGUI.App">
    <!-- I added xmlns:local="using:PcPunditCrawlerGUI"  -->
    <Application.DataTemplates>
        <local:ViewLocator/>
    </Application.DataTemplates>

    <Design.DataContext>
        <Application/>
    </Design.DataContext>


    <Application.Styles>
        <FluentTheme Mode="Dark"/>
        <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
    </Application.Styles>

    <TrayIcon.Icons>
        <TrayIcons>
            <TrayIcon Icon="/Assets/titlebar_icon.ico" Command="{Binding ShowOrHideCommand}" ToolTipText="PCPundit Crawler">
                <NativeMenu.Menu>
                    <NativeMenu>                        
                        <NativeMenuItem Header="{Binding ShowOrHide}" Command="{Binding ShowOrHideCommand} " />                     
                        <NativeMenuItemSeparator />
                        <NativeMenuItem Header="Exit" Command="{Binding Exit}" />
                    </NativeMenu>
                </NativeMenu.Menu>
            </TrayIcon>
        </TrayIcons>
    </TrayIcon.Icons>
</Application>
Enter fullscreen mode Exit fullscreen mode

And my App.axaml.cs

   public partial class App : Application, INotifyPropertyChanged
    {
        public new event PropertyChangedEventHandler? PropertyChanged;
        private MainWindow? _myMainWindow;
        private bool _isHidden = false;
        public string ShowOrHide{ get => _isHidden ? "Show" : "Hide"; }

        private void MyMainWindow_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
        {
            if (sender is MainWindow && e.NewValue is WindowState windowState)
            {
                if(windowState == WindowState.Minimized)
                { 
                    _isHidden = true;
                    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowOrHide)));
                    _myMainWindow?.Hide();
                }
                else
                {
                    _isHidden = false;
                    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowOrHide)));
                    _myMainWindow?.Show();
                }
            }
        }

        public override void Initialize()
        {
            AvaloniaXamlLoader.Load(this);
        }

        public override void OnFrameworkInitializationCompleted()
        {
            if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
            {
                _myMainWindow = new MainWindow{ DataContext = new MainWindowViewModel() };
                desktop.MainWindow = _myMainWindow;
                _myMainWindow.PropertyChanged += MyMainWindow_PropertyChanged;
                DataContext = this;
            }
            base.OnFrameworkInitializationCompleted();
        }

        public void ShowOrHideCommand()
        {
            if (_isHidden)
                Show();
            else
                Hide();
        }

        public void Show()
        {
            if (_myMainWindow != null)
            {
                _myMainWindow.WindowState = WindowState.Normal;
                _myMainWindow.Show();
            }
        }

        public void Hide()
        {
            if(_myMainWindow != null)
            {
                _myMainWindow.WindowState = WindowState.Minimized;
                _myMainWindow.Hide();
            }
        }

        public void Exit()
        {
            _myMainWindow?.Close();            
        }
    }
Enter fullscreen mode Exit fullscreen mode

Disclaimer: I'm posting this rather late at night right as I got it working, don't know if further propagating the MainWindow.PropertyChanged event is the way to go. It does however work.

Collapse
 
stipecmv profile image
Stipec

Nice work, I was also wondering how to do it in View,(but as I wrote above, currently I do not have time to dive deeply in this topic to understand it complety,) because TrayIcon is UI component :)

Collapse
 
stipecmv profile image
Stipec

That is nice that my article helped someone :) I am working on different thing so my coding hobby went a little bit aside. Yes Avalonia offers multiple ways of loading images, it depends which suits you :)
The problem you mentioned I havent noticed when I played with it :O but indeed it needs to be fixed :)