Implement dirty notification on avalon doc tab + caliburn micro

I got a problem to solve when trying to implement the VS-like dirty notification in my WPF app,

I realized the way I opened the AvalonDockTab in my previous post is not very friendly to make this happen.

When ViewModel changed the displayname of Screen after the tab openning, it’s very hard the notify view to refresh it.

Using code to set databinding is a much better solution.


private DocumentContent CreateTab(object view, Screen currentViewModel)
{
	var documentContent = new DocumentContent
							  {
								  DataContext =  (currentViewModel),
								  Content = view,
//                Title = currentViewModel.DisplayName, // should set it via databinding, if vm changed it later, how to refresh title?

							  };

	BindTabTitle(documentContent);

//            documentContent.SetBinding(TitleProperty, new Binding("DisplayName"));  //why this does not work?
	return documentContent;
}

static void BindTabTitle(DocumentContent tab)
{
	DependencyProperty textProp = DocumentContent.TitleProperty;
	if (!BindingOperations.IsDataBound(tab, textProp))
	{
		Binding b = new Binding("DisplayName");
		BindingOperations.SetBinding(tab, textProp, b);
	}
}
//... in view model
        private string _name;
        public string Name
        {
            get { return _name; }
            set {
                _name = value;
                DisplayName = "Product details: " + _name;

                if (_name != _product.Name) DisplayName += "*";
            }
        }
	private void SetOriginalData()
	{
		Name = _product.Name;
		Description = _product.Description;
	}

	public void Save()
	{
		_product.Name = Name;
		_product.Description = Description;

		try
		{
			_productCrudTask.Save(_product);

			_messageBox.Show("Product " + Name + " saved successfully.");

			SetOriginalData(); // to reset dirty flag, still looking for a better way.

		}
		catch (DataValidationException e)
		{
			_messageBox.Show("Error! " + e.Message);
		}
	}

	public void Cancel()
	{
		SetOriginalData();
	}

Advertisements

When Caliburn.Micro meets AvalonDock

Using classic WPF tabcontrol in Caliburn.Micro is very easy, view model just needs to inherited from Conductor<IScreen>.Collection.OneActive, then add sub-viewmodels into Items property, or call ActivateItem(). The view side only need to name the tablecontrol to “Items”, everything is done! (WPF only, SilverLight app needs to implement tabitemconverter explicitly)

But, in some fancy used user control world, e.g., AvalonDock, this doesn’t work anymore, because Caliburn magic just automatically find those mapping views and then convert them to tabitems, it doesn’t know Avalon Control at all.

Sure, we can create our own TabItemConverter.


   public class TabItemConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (value is BindableCollection<IScreen>)
            {
                var screenList = (BindableCollection<IScreen>)value;

                List<DocumentContent> result = new List<DocumentContent>();

                foreach (var screen in screenList)
                {
                    var view = LocateViewFor(screen);

                    var tabItem = new DocumentContent();
                    ViewModelBinder.Bind(screen, tabItem, null);
                    tabItem.Content = view; //TODO: can this be done by xaml caliburn.View
                    BindTabTitle(tabItem);
                    result.Add(tabItem);

                }
                return result;

            }

            return null;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }

        private object LocateViewFor(object viewModel)
        {
            var view = ViewLocator.LocateForModelType(viewModel.GetType(), null, null);
            return view;
        }

        static void BindTabTitle(DocumentContent tab)
        {
            DependencyProperty textProp = DocumentContent.TitleProperty;
//            if (!BindingOperations.IsDataBound(tab, textProp)) // Not available in Silverlight
            {
                Binding b = new Binding("DisplayName");
                BindingOperations.SetBinding(tab, textProp, b);
            }
        }


                <ad:DocumentPane ItemsSource="{Binding Items, Converter={StaticResource TabItemConverter}}" SelectedItem="{Binding ActiveItem, Mode=TwoWay}" >

                </ad:DocumentPane>

It works, sort of. Opening tab looks fine, but active doesn’t. Any still have some goofy issues, like when click the dropdown icon at the upper right corner will kill the app!?It probably because the ItemsSource binding doesn’t work very well with AvalonDock control.

I created the workaround for Caliburn + Avalondock, now with the new slim but powerful Caliburn.Micro framewrok, this task is much easier, thanks to the new static classes: ViewLocator and ViewModelBinder.

Details: it was a problem in the pure view based design world. In shellView, we can easily do this:


            var dockableContent = new DockableContent
                                      {
                                          Content = new SearchView(),
                                      };

            dockManager.Show(dockableContent, DockableContentState.Docked, AnchorStyle.Left);

The idea of view model based design is to remove the new SearchView(), instead should rely completely on shellViewModel to handle all the creation, by calling ActivateItem(searchViewModel).

The problem is, AvalonDock doesn’t behave as the classic tabcontrol, we can not just bind the items into it’s datacontext.

The solution, use event handler between view and view model:

Code:


   public class ShellViewModel : Conductor<Screen>, IShellViewModel
    {
        private readonly IMessageBox _messageBox;
        private IShellView _view;

        public ShellViewModel(IMessageBox messageBox)
        {
            _messageBox = messageBox;
            RequestViewToLoadDockPanel = (o, e) => { };
        }

        protected override void OnViewLoaded(object view)
        {
            _view = (IShellView) view;
            RequestViewToLoadDockPanel += _view.LoadDockPanel;

            ShowLeftDockPanel();
        }

        private void ShowLeftDockPanel()
        {
            var searchViewModel = new SearchViewModel(_messageBox);
            ActivateItem(searchViewModel);
            RequestViewToLoadDockPanel(this, new ScreenEventArgs(searchViewModel));
        }

        public event EventHandler<ScreenEventArgs> RequestViewToLoadDockPanel;
    }

    public interface IShellView
    {
        void LoadDockPanel(object sender, ScreenEventArgs e);
    }

    public class ScreenEventArgs : EventArgs
    {
        public Screen Screen { get; set; }

        public ScreenEventArgs(Screen screen)
        {
            Screen = screen;
        }
    }

    public partial class ShellView : Window, IShellView
    {
        public ShellView()
        {
            InitializeComponent();
        }

        #region IShellView Members

        public void LoadDockPanel(object sender, ScreenEventArgs e)
        {
            var view =   LocateViewFor(e.Screen);

            var dockableContent = new DockableContent
                                      {
                                          Content = view,
                                      };

            dockManager.Show(dockableContent, DockableContentState.Docked, AnchorStyle.Left);
        }

        #endregion

        private object LocateViewFor(object viewModel)
        {
            var view = ViewLocator.LocateForModel(viewModel, null, null);

            ViewModelBinder.Bind(viewModel, view, null);

            return view;
        }
    }