Gadu gadu Skype
lis
20

Visual State Manager

Spook, Listopad 20 2011

Napisz komentarz

Kiedy piszemy aplikację okienkową, prędzej czy później dochodzi do sytuacji, w której trzeba powiązać interfejs użytkownika z pewnymi metodami w kodzie. W przypadku prostych programików problem w zasadzie nie istnieje, ale gdy aplikacja rozrasta się, sytuacja staje się nieco bardziej skomplikowana.

Przyjmijmy, że pojedynczą funkcjonalność wywoływaną z poziomu interfejsu użytkownika nazwiemy akcją. Podstawowym problemem jest fakt, iż do akcji można dostać się z wielu różnych miejsc w interfejsie użytkownika. Na przykład może być ona dostępna równolegle z poziomu menu aplikacji, paska narzędzi oraz menu kontekstowego. W takiej sytuacji nie ma już mowy o oprogramowywaniu zdarzeń elementów interfejsu (w sensie: implementowania akcji wewnątrz metody obsługi zdarzenia), jak można byłoby zrobić w prostej aplikacji, ponieważ utrzymywanie takiego kodu będzie horrorem. Rozwiązanie tego problemu nie jest zbyt trudne – wystarczy samą funkcjonalność przenieść do osobnej metody i wywoływać ją w metodach obsługujących zdarzenia.

To jednak nie wszystko. Często zdarza się, że pewną akcję można wywołać tylko w odpowiednich warunkach. Na przykład edycja i usunięcie elementu znajdującego się na liście jest możliwa tylko wówczas, gdy element ten jest zaznaczony. Oczywiście w odpowiednich metodach można sprawdzać zawsze, czy wszystkie konieczne warunki są spełnione, ale takie rozwiązanie jest niewygodne z punktu widzenia użytkownika. Znacznie mniej frustrująca jest sytuacja, w której jasno widać, że akcja jest niedostępna, niż gdy wprawdzie można ją wywołać, ale tylko po to, aby dostać po oczach komunikatem ostrzegawczym (lub, o zgrozo, bez żadnego efektu).

Notepad - zawijanie wierszy

Spośród akcji można wyróżnić też takie, których zadaniem jest przełączenie pewnego stanu. Przykładem może być zawijanie wierszy w Notatniku: haczyk obok odpowiedniej pozycji menu informuje o tym, czy dana akcja jest aktywna, czy nie.

Ustawienie własności Enabled danej kontrolki w sytuacji, gdy zmiana warunków powoduje uniemożliwienie wywołania odpowiadającej jej akcji nie jest problemem. Gorzej, gdy po pierwsze – jak już wspominałem – akcja jest wywoływana z poziomu kilku kontrolek, a po drugie – warunków wymaganych do wywołania akcji jest kilka. Zauważmy też, że przeciętna aplikacja zawiera tych akcji kilkanaście lub kilkadziesiąt. Próba uzmysłowienia sobie, ile warunków w różnych miejscach trzeba byłoby wprowadzić, aby ręcznie aktualizować stany akcji może przyprawić o ból głowy.

Dużo frameworków wprowadza gotowe rozwiązania. Delphi udostępnia użytkownikom niewizualny komponent TActionManager, w którym można definiować akcje oraz odpowiadające im kontrolki. Windows Presentation Foundation oferuje podobny, bardzo wygodny w użyciu mechanizm. Ja chciałbym zaprezentować prostego managera przydatnego w sytuacji, gdy framework nie udostępnia nam odpowiedniego gotowca lub gdy nie chcemy korzystać z dużego rozwiązania, być może znacznie przerastającego nasze potrzeby.

Mój pomysł opiera się na trzech elementach: State, Condition i Action. Rozpatrzmy je po kolei.

State

Obiekt State jest odpowiedzialny za przechowywanie informacji o pojedynczym atomowym stanie jakiegoś elementu aplikacji. Na przykład może to być informacja o tym, czy w liście został zaznaczony jakiś element lub czy użytkownik zaznaczył jakiś tekst w polu tekstowym. Stan musi być użyty w taki sposób, by można było łatwo i jednoznacznie określić jego wartość, a także by możliwie łatwo można było go aktualizować. Na przykład kontrolka listy elementów udostępnia zdarzenie informujące o tym, że zmienił się indeks zaznaczonego elementu, zaś pole tekstowe – również poprzez zdarzenie – informuje o zmianie zaznaczenia. W obsłudze tych zdarzeń można łatwo określić wartość odpowiadającego im stanu.

Celem istnienia tej klasy jest ujednolicenie dostępu do warunków mogących wpływać na stany akcji.

Oto przykładowa implementacja klasy stanu w C#:

public class StateChangedEventArgs : EventArgs
{
  private bool newValue;

  public StateChangedEventArgs()
  {
    newValue = true;
  }

  public StateChangedEventArgs(bool newNewValue)
  {
    newValue = newNewValue;
  }

  public bool NewValue
  {
    get
    {
      return newValue;
    }
    set
    {
      newValue = value;
    }
  }
}

public delegate void StateChangedDelegate(
  object State, 
  StateChangedEventArgs e);

public class State
{
  private bool value;

  public State(bool initialValue = true)
  {
    value = initialValue;
    StateChanged = null;
  }

  public bool Value
  {
    get
    {
      return value;
    }
    set
    {
      this.value = value;
      if (StateChanged != null)
        StateChanged(this, new StateChangedEventArgs(this.value));
    }
  }

  public event StateChangedDelegate StateChanged;
}

Condition

Condition jest klasą, która agreguje kilka różnych stanów. Dzięki temu jeden stan może być wykorzystywany w kilku różnych warunkach (być może w połączeniu z różnymi innymi stanami). Przykładowa implementacja w C# jest następująca:

public class ConditionChangedEventArgs : EventArgs
{
    private bool newValue;

    public ConditionChangedEventArgs()
    {
        newValue = true;
    }

    public ConditionChangedEventArgs(bool newNewValue)
    {
        newValue = newNewValue;
    }

    public bool NewValue
    {
        get
        {
            return newValue;
        }
        set
        {
            newValue = value;
        }
    }
}

public delegate void ConditionChangedDelegate(
  object Condition, 
  ConditionChangedEventArgs e);

public enum ConditionKind
{
    And,
    Or
}

public class Condition
{
    private List<State> stateList;
    private ConditionKind conditionKind;
    private bool value;

    private void StateChanged(object State, StateChangedEventArgs e)
    {
        EvaluateCondition();
    }

    private void EvaluateCondition()
    {
        if (stateList.Count == 0)
        {
            if (value != false)
            {
                value = false;

                if (ConditionChanged != null)
                    ConditionChanged(this, 
                      new ConditionChangedEventArgs(false));
            }
        }
        else
        {
            switch (conditionKind)
            {
                case ConditionKind.And:
                    {
                        bool tmpValue = true;
                        int i = 0;
                        while (i < stateList.Count && tmpValue)
                        {
                            tmpValue &= stateList[i].Value;
                            i++;
                        }

                        if (value != tmpValue)
                        {
                            value = tmpValue;

                            if (ConditionChanged != null)
                                ConditionChanged(this, 
                                  new ConditionChangedEventArgs(tmpValue));
                        }

                        break;
                    }
                case ConditionKind.Or:
                    {
                        bool tmpValue = false;
                        int i = 0;
                        while (i < stateList.Count && !tmpValue)
                        {
                            tmpValue |= stateList[i].Value;
                            i++;
                        }

                        if (value != tmpValue)
                        {
                            value = tmpValue;

                            if (ConditionChanged != null)
                                ConditionChanged(this, 
                                  new ConditionChangedEventArgs(tmpValue));
                        }

                        break;
                    }
            }
        }
    }

    public Condition()
    {
        stateList = new List<State>();
        value = false;
        conditionKind = ConditionKind.And;
    }

    public void AddState(State state)
    {
        int i = 0;
        while (i < stateList.Count && stateList[i] != state)
            i++;

        if (i < stateList.Count)
            return;

        state.StateChanged += StateChanged;
        stateList.Add(state);

        EvaluateCondition();
    }

    public void RemoveState(State state)
    {
        int i = 0;
        while (i < stateList.Count && stateList[i] != state)
            i++;

        if (i == stateList.Count)
            return;

        state.StateChanged -= StateChanged;
        stateList.RemoveAt(i);

        EvaluateCondition();
    }

    public ConditionKind ConditionKind
    {
        get
        {
            return conditionKind;
        }
        set
        {
            conditionKind = value;
            EvaluateCondition();
        }
    }

    public bool Value
    {
        get
        {
            return value;
        }
    }

    public event ConditionChangedDelegate ConditionChanged;
}

Action

Klasa Action opisuje pewną akcję. Powinna ona mieć następujące cechy:

  • Po powiązaniu z kontrolkami, powinna automatycznie przywiązać się do odpowiednich zdarzeń typu Click.
  • Powinna mieć własności typu Condition, regulujące dostępność (Enabled), widoczność (Visibility) oraz zaznaczenie (Down, Checked itp.) kontrolek, które są powiązane z akcją.
  • Powinna mieć zdarzenie Executed, wywoływane w momencie wybrania którejkolwiek z kontrolek, za które odpowiada akcja.

Oto przykładowa implementacja klasy Action:

public delegate void ActionExecutedDelegate(
  object Action, 
  EventArgs e);

public class Action
{
    private Condition enabledCondition;
    private Condition visibleCondition;
    private Condition checkedCondition;
    private List<object> controlList;

    private void ApplyEnabled(int i, bool newValue)
    {
        if (controlList[i] is Button)
            ((Button)controlList[i]).Enabled = newValue;
        else if (controlList[i] is ToolStripMenuItem)
            ((ToolStripMenuItem)controlList[i]).Enabled = newValue;
        else if (controlList[i] is ToolStripButton)
            ((ToolStripButton)controlList[i]).Enabled = newValue;
        else
            throw new InvalidOperationException(
              "Internal control list contains not supported control!");
    }

    private void ApplyEnabledToAll(bool newValue)
    {
        for (int i = 0; i < controlList.Count; i++)
            ApplyEnabled(i, newValue);
    }

    private void ApplyVisible(int i, bool newValue)
    {
        if (controlList[i] is Button)
            ((Button)controlList[i]).Visible = newValue;
        else if (controlList[i] is ToolStripMenuItem)
            ((ToolStripMenuItem)controlList[i]).Visible = newValue;
        else if (controlList[i] is ToolStripButton)
            ((ToolStripButton)controlList[i]).Visible = newValue;
        else
            throw new InvalidOperationException(
              "Internal control list contains not supported control!");
    }

    private void ApplyVisibleToAll(bool newValue)
    {
        for (int i = 0; i < controlList.Count; i++)
            ApplyVisible(i, newValue);
    }

    private void ApplyChecked(int i, bool newValue)
    {
        if (controlList[i] is Button)
            return;
        else if (controlList[i] is ToolStripMenuItem)
            ((ToolStripMenuItem)controlList[i]).Checked = newValue;
        else if (controlList[i] is ToolStripButton)
            ((ToolStripButton)controlList[i]).Checked = newValue;
        else
            throw new InvalidOperationException(
              "Internal control list contains not supported control!");
    }

    private void ApplyCheckedToAll(bool newValue)
    {
        for (int i = 0; i < controlList.Count; i++)
            ApplyChecked(i, newValue);
    }

    private void EnabledConditionChanged(object Condition, 
      ConditionChangedEventArgs e)
    {
        ApplyEnabledToAll(e.NewValue);
    }

    private void VisibleConditionChanged(object Condition, 
      ConditionChangedEventArgs e)
    {
        ApplyVisibleToAll(e.NewValue);
    }

    private void CheckedConditionChanged(object Condition, 
      ConditionChangedEventArgs e)
    {
        ApplyCheckedToAll(e.NewValue);
    }

    private void ToolStripMenuItemClicked(object sender, 
      EventArgs e)
    {
      if (ActionExecuted != null)
            ActionExecuted(this, new EventArgs());
    }

    private void ButtonClicked(object sender, EventArgs e)
    {
      if (ActionExecuted != null)
            ActionExecuted(this, new EventArgs());
    }

    void ToolStripButonClicked(object sender, EventArgs e)
    {
        if (ActionExecuted != null)
            ActionExecuted(this, new EventArgs());
    }

    public Action()
    {
        controlList = new List<object>();
        ActionExecuted = null;
        enabledCondition = null;
        visibleCondition = null;
        checkedCondition = null;
    }

    public void AddControl(object control)
    {
        int i = 0;
        while (i < controlList.Count && controlList[i] != control)
            i++;

        if (i < controlList.Count)
            return;

        if (control is Button)
            ((Button)control).Click += ButtonClicked;
        else if (control is ToolStripMenuItem)
            ((ToolStripMenuItem)control).Click += 
              ToolStripMenuItemClicked;
        else if (control is ToolStripButton)
            ((ToolStripButton)control).Click += 
              ToolStripButonClicked;
        else
            throw new ArgumentException("control", 
              "This control is not supported!");

        controlList.Add(control);

        if (enabledCondition != null)
            ApplyEnabled(controlList.Count - 1, 
              enabledCondition.Value);
        if (visibleCondition != null)
            ApplyVisible(controlList.Count - 1, 
              visibleCondition.Value);
        if (checkedCondition != null)
            ApplyChecked(controlList.Count - 1, 
              checkedCondition.Value);
    }

    public void RemoveControl(object control)
    {
        int i = 0;
        while (i < controlList.Count && 
          controlList[i] != control)
            i++;

        if (i == controlList.Count)
            return;

        if (control is Button)
            ((Button)control).Click -= ButtonClicked;
        else if (control is ToolStripMenuItem)
            ((ToolStripMenuItem)control).Click -= 
              ToolStripMenuItemClicked;
        else if (control is ToolStripButton)
            ((ToolStripButton)control).Click -= 
              ToolStripButonClicked;
        else
            throw new ArgumentException("control", 
              "This control is not supported!");
    }

    public void Execute()
    {
        if ((enabledCondition != null && 
            enabledCondition.Value == true) || 
          enabledCondition == null)
        {
            if (ActionExecuted != null)
                ActionExecuted(this, new EventArgs());
        }
    }

    public Condition EnabledCondition
    {
        get
        {
            return enabledCondition;
        }
        set
        {
            if (enabledCondition != null)
                enabledCondition.ConditionChanged -= 
                  EnabledConditionChanged;

            enabledCondition = value;

            if (enabledCondition != null)
            {
                enabledCondition.ConditionChanged += 
                  EnabledConditionChanged;

                ApplyEnabledToAll(enabledCondition.Value);
            }
        }
    }

    public Condition VisibleCondition
    {
        get
        {
            return visibleCondition;
        }
        set
        {
            if (visibleCondition != null)
                visibleCondition.ConditionChanged -= 
                  VisibleConditionChanged;

            visibleCondition = value;

            if (visibleCondition != null)
            {
                visibleCondition.ConditionChanged += 
                  VisibleConditionChanged;

                ApplyVisibleToAll(visibleCondition.Value);
            }
        }
    }

    public Condition CheckedCondition
    {
        get
        {
            return checkedCondition;
        }
        set
        {
            if (checkedCondition != null)
                checkedCondition.ConditionChanged -= 
                  CheckedConditionChanged;

            checkedCondition = value;

            if (checkedCondition != null)
            {
                checkedCondition.ConditionChanged += 
                  CheckedConditionChanged;

                ApplyCheckedToAll(checkedCondition.Value);
            }
        }
    }
    
    public bool CanExecute
    {
        get
        {
            if (enabledCondition != null)
                return enabledCondition.Value;
            else
                return true;
        }
    }

    public event ActionExecutedDelegate ActionExecuted;
}

Przykład

Zobaczmy, w jaki sposób wykorzystać tak przygotowany mechanizm.

Załóżmy, że mamy program z przyciskiem, który powinien być dostępny tylko wówczas, gdy zaznaczony jest jakiś element listy oraz gdy włączony jest znajdujący się na formatce checkbox. Tworzymy aplikację z formatką, na której znajduje się przycisk, listbox i checkbox. Pierwszym krokiem jest przygotowanie naszej architektury stanów, warunków i akcji. Ponieważ jest to funkcjonalność, która powinna działać "za kurtyną", większą jej część umieściłem w pliku Form1.Designer.cs.

Pierwszym krokiem jest przygotowanie odpowiednich pól klasy formatki. W pliku Form1.Designer.cs dopisujemy następujące linijki:

  // Wstawione przez designera
  private System.Windows.Forms.Button button1;
  private System.Windows.Forms.ListBox listBox1;
  private System.Windows.Forms.CheckBox checkBox1;

  // Te dopisujemy
  private State listboxSelectionState;
  private State checkboxCheckedState;

  private Condition buttonActionAvailable;

  private Action buttonAction;
}

Następnie przygotowujemy metodę, która zainicjuje wszystkie obiekty naszego mechanizmu.

private void InitializeActions()
{
  listboxSelectionState = new State(false);

  checkboxCheckedState = new State(false);

  buttonActionAvailable = new Condition();

  // Tutaj definiujemy, które stany będą
  // wpływały na dany warunek.
  buttonActionAvailable.AddState(
    listboxSelectionState);
  buttonActionAvailable.AddState(
    checkboxCheckedState);

  buttonAction = new Action();

  // Przypinamy do akcji kontrolkę

  buttonAction.AddControl(button1);

  // Definiujemy warunek dostępności
  // akcji

  buttonAction.EnabledCondition = buttonActionAvailable;

  // Określamy metodę wywoływaną
  // po wywołaniu akcji.

  buttonAction.ActionExecuted += buttonAction_ActionExecuted;
}

Zgodnie z ostatnią linijką potrzebna nam będzie metoda buttonAction_ActionExecuted. Jej zadaniem będzie wykonanie akcji dostępnej po wciśnięciu przycisku. Jednak tutaj kończy się część działań "za kurtyną", więc ograniczymy się w jej implementacji do wywołania metody, która znajdzie się w pliku Form1.cs.

void buttonAction_ActionExecuted(object Action, System.EventArgs e)
{
  DoExecuteAction();
}

Tyle zmian w pliku Form1.Designer.cs. Przejdźmy teraz do Form1.cs. Na początku zaimplementujmy naszą akcję.

private void DoExecuteAction()
{
  MessageBox.Show("Akcja wykonana!");
}

Teraz wzbogaćmy konstruktor o wywołanie naszej metody inicjującej akcje. Zauważmy, że akcje odwołują się do zdarzeń przypinanych do nich kontrolek, więc – co ważne – kontrolki muszą istnieć, gdy konfigurujemy akcje. Dlatego też metoda inicjująca akcje wywołana zostanie już po wywołaniu metody inicjującej komponenty.

public Form1()
{
  InitializeComponent();
  InitializeActions();
}

Na koniec pozostało nam tylko oprogramowanie aktualizacji stanów, gdy zmienione zostanie zaznaczenie listy plików lub gdy zmieni się stan checkboxa.

private void listBox1_SelectedIndexChanged(object sender, EventArgs e)
{
  listboxSelectionState.Value = listBox1.SelectedIndex >= 0;
}

private void checkBox1_CheckedChanged(object sender, EventArgs e)
{
  checkboxCheckedState.Value = checkBox1.Checked;
}

I to wszystko. Wielką zaletą rozwiązania jest fakt, że definicja zależności (która z reguły raz napisana, nie będzie się już zmieniać, a przynajmniej nieczęsto) siedzi w Form1.Designer.cs, zaś faktyczny kod aplikacji zamyka się w czterech metodach, z których najdłuższa ma dwie linijki. Sprawdźmy, czy wszystko działa.

Form1 - kontrolka wyłączona
Form1 - kontrolka włączona

Chętnie wysłucham pomysłów na udoskonalenie powyższego mini-frameworka. Zachęcam do komentowania.

Tagi: , , ,

Skomentuj