tuke307
tuke307

Reputation: 430

How to update/convert mumeric TextBox value when changing value's unit using a ComboBox? Value normalization based on current unit?

I would like to have a converter system for my Xamarin and WPF project. I don't want to save any units in the database, so I want directly convert the textbox-values when user change the unit.

enter image description here

I made public a few Observable Collections like;

 public class AreaList : ObservableCollection<Unit>
    {
        public AreaList() : base()
        {
            Add(new Unit("mm²"));
            Add(new Unit("cm²"));
            Add(new Unit("dm²"));
            Add(new Unit("m²"));
        }
    }

 public class Unit
    {
        private string name;

        public Unit(string name)
        {
            this.name = name;
        }

        public string Name
        {
            get { return name; }
            set { name = value; }
        }
    }

In the View i bind the collection to my combo box. I gave my TextBox the name of his binding property(Text="{Binding TxtBoxValue}" => x:Name="TxtBoxValue"). The ConvertUnitValueCommand set this name as a string in the view model to know which variable the converter function should use when the unit is changed.

View

<UserControl.Resources>
        <c:AreaList x:Key="AreaListData" />
</UserControl.Resources>

<TextBox x:Name="TxtBoxValue"
         Text="{Binding Mode=TwoWay, Path=TxtBoxValue, UpdateSourceTrigger=PropertyChanged}">
</TextBox>

<ComboBox IsSynchronizedWithCurrentItem="True"
          IsEditable="False"
          DisplayMemberPath="Name"
          SelectedItem="{Binding Unit,Mode=OneWayToSource}"
          ItemsSource="{Binding Source={StaticResource AreaListData}}">
<i:Interaction.Triggers>
   <i:EventTrigger EventName="PreviewMouseLeftButtonDown">
         <i:InvokeCommandAction Command="{Binding ConvertUnitValueCommand}"
                                CommandParameter="{Binding ElementName=TxtBoxValue, Path=Name}" />
   </i:EventTrigger>
</i:Interaction.Triggers> 
</ComboBox>

ViewModel

private string ConvertControlName;

private void ConvertUnitValue(object obj)
{
    ConvertControlName = obj.ToString();
}

public Unit Unit
{
get => Get<Unit>();
set
{
     if (ConvertControlName != null)
     {
    FieldInfo variable = this.GetType().GetField(ConvertControlName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic);

    //Get the Value from setted Binding Variable
    double oldValue = (double)variable.GetValue(this);

    //Convert the value
    if (oldValue > 0)
    {
         double newValue = Converts.ConvertUnitValue(Unit, value, oldValue);
         variable.SetValue(this, newValue);
    }

    Set(value);
    }
}

Maybe anyone can give me some inspiration to do it better.

Upvotes: 0

Views: 707

Answers (2)

BionicCode
BionicCode

Reputation: 29028

The following example normalizes the user input to the base unit :

Unit.cs

public class Unit
{
  public Unit(string name, decimal baseFactor)
  {
    this.Name = name;
    this.BaseFactor = baseFactor;
  }

  #region Overrides of Object

  /// <inheritdoc />
  public override string ToString() => this.Name;

  #endregion

  public string Name { get; set; }
  public decimal BaseFactor { get; set; }
}

ViewModel.cs

public class ViewModel : INotifyPropertyChanged
{
  public ViewModel()
  {
    this.Units = new List<Unit>()
    {
      new Unit("mm²", (decimal) (1 / Math.Pow(1000, 2))),
      new Unit("cm²", (decimal) (1 / Math.Pow(100, 2))),
      new Unit("dm²", (decimal) (1 / Math.Pow(10, 2))),
      new Unit("m²", 1)
    };
  }

  private void NormalizeValue()
  {
    this.NormalizedValue = this.UnitValue * this.SelectedUnit.BaseFactor;
  }

  private List<Unit> units;
  public List<Unit> Units
  {
    get => this.units;
    set
    {
      this.units = value;
      OnPropertyChanged();
    }
  }

  private Unit selectedUnit;
  public Unit SelectedUnit
  {
    get => this.selectedUnit;
    set
    {
      this.selectedUnit = value;
      OnPropertyChanged();

      NormalizeValue();
    }
  }

  private decimal unitValue;
  public decimal UnitValue
  {
    get => this.unitValue;
    set
    {
      this.unitValue = value;
      OnPropertyChanged();

      NormalizeValue();
    }
  }

  private decimal normalizedValue;
  public decimal NormalizedValue
  {
    get => this.normalizedValue;
    set
    {
      this.normalizedValue = value;
      OnPropertyChanged();
    }
  }

  public event PropertyChangedEventHandler PropertyChanged;
  protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
  {
    this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  }
}

ManiWindow.xaml

<Window>
  <Window.DataContext>
    <ViewModel />
  </Window.DatContext>

  <StackPanel>

    <!-- Input -->
    <TextBox Text="{Binding UnitValue}" />
    <ComboBox ItemsSource="{Binding Units}" 
              SelectedItem="{Binding SelectedUnit}" />

    <TextBlock Text="{Binding NormalizedValue}" />
  </StackPanel>
</Window>

Reusable solution

A reusable solution would be to create a custom control, which derives from TextBox and encapsulates the normalization logic and the control design.

The following custom control NormalizingNumericTextBox extends TextBox and converts two way from non-normalized value to normalized and back.
It is basically a TextBox aligned with a ComboBox as Unit selector.
It may not be perfect, but it is ready to use and it just took me about 10 minutes to merge the previous answer into this custom control.

NormalizingNumericTextBox supports any type of unit describing a numeric value.
Just bind the NormalizingNumericTextBox.Units property to collection of any kind of Unit implementation e.g. weight, length, currency, etc.

Bind to NormalizingNumericTextBox.NormalizedValue to get/set the normalized value. Setting this property will convert the value to the current NormalizingNumericTextBox.SelectedUnit.
Bind to NormalizingNumericTextBox.Text for the raw input value.

Ensure that the default Style (see below) is added to the ResourceDictionary inside /Themes/Generic.xaml. Customize this Style to customize appearance.

ManiWindow.xaml

<Window>
  <Window.DataContext>
    <ViewModel />
  </Window.DatContext>

  <StackPanel>

    <!-- Input -->
      <NormalizingUnitTextBox NormalizedValue="{Binding NormalizedValue}" 
                              Units="{Binding Units}"
                              Width="180" />

    <!-- 
      Test to show/manipulate current normalized value of the view model.
      An entered normalized value will be converted back to the current NormalizingNumericTextBox.Unit -->
    <TextBox Background="Red" Text="{Binding NormalizedUnitValue}"/>
  </StackPanel>
</Window>

Unit.cs

public class Unit
{
  public Unit(string name, decimal baseFactor)
  {
    this.Name = name;
    this.BaseFactor = baseFactor;
  }

  #region Overrides of Object

  /// <inheritdoc />
  public override string ToString() => this.Name;

  #endregion

  public string Name { get; set; }
  public decimal BaseFactor { get; set; }
}

ViewModel.cs

public class ViewModel : INotifyPropertyChanged
{
  public ViewModel()
  {
    this.Units = new List<Unit>()
    {
      new Unit("m²", 1),
      new Unit("dm²", (decimal) (1/Math.Pow(10, 2))),
      new Unit("cm²", (decimal) (1/Math.Pow(100, 2))),
      new Unit("mm²", (decimal) (1/Math.Pow(1000, 2)))
    };
  }

  public List<Unit> Units { get; set; }

  private decimal normalizedValue;
  public decimal NormalizedValue
  {
    get => this.normalizedValue;
    set
    {
      this.normalizedValue = value;
      OnPropertyChanged();
    }
  }

  public event PropertyChangedEventHandler PropertyChanged;
  protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
  {
    this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  }
}

NormalizingNumericTextBox.cs

[TemplatePart(Name = "PART_UnitsItemsHost", Type = typeof(ItemsControl))]
public class NormalizingNumericTextBox : TextBox
{
  public static readonly DependencyProperty UnitsProperty = DependencyProperty.Register(
    "Units",
    typeof(IEnumerable<Unit>),
    typeof(NormalizingNumericTextBox),
    new PropertyMetadata(default(IEnumerable<Unit>), NormalizingNumericTextBox.OnUnitsChanged));

  public IEnumerable<Unit> Units
  {
    get => (IEnumerable<Unit>) GetValue(NormalizingNumericTextBox.UnitsProperty);
    set => SetValue(NormalizingNumericTextBox.UnitsProperty, value);
  }

  public static readonly DependencyProperty SelectedUnitProperty = DependencyProperty.Register(
    "SelectedUnit",
    typeof(Unit),
    typeof(NormalizingNumericTextBox),
    new FrameworkPropertyMetadata(
      default(Unit),
      FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
      NormalizingNumericTextBox.OnSelectedUnitChanged));

  public Unit SelectedUnit
  {
    get => (Unit) GetValue(NormalizingNumericTextBox.SelectedUnitProperty);
    set => SetValue(NormalizingNumericTextBox.SelectedUnitProperty, value);
  }

  public static readonly DependencyProperty NormalizedValueProperty = DependencyProperty.Register(
    "NormalizedValue",
    typeof(decimal),
    typeof(NormalizingNumericTextBox),
    new FrameworkPropertyMetadata(
      default(decimal),
      FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
      NormalizingNumericTextBox.OnNormalizedValueChanged));

  public decimal NormalizedValue
  {
    get => (decimal) GetValue(NormalizingNumericTextBox.NormalizedValueProperty);
    set => SetValue(NormalizingNumericTextBox.NormalizedValueProperty, value);
  }

  private ItemsControl PART_UnitsItemsHost { get; set; }
  private bool IsNormalizing { get; set; }

  static NormalizingNumericTextBox()
  {
    FrameworkElement.DefaultStyleKeyProperty.OverrideMetadata(
      typeof(NormalizingNumericTextBox),
      new FrameworkPropertyMetadata(typeof(NormalizingNumericTextBox)));
  }

  public NormalizingNumericTextBox()
  {
  }

  private static void OnNormalizedValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    var _this = d as NormalizingNumericTextBox;
    _this.ConvertNormalizedValueToNumericText();
  }

  private static void OnSelectedUnitChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    (d as NormalizingNumericTextBox).NormalizeNumericText();
  }

  private static void OnUnitsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    var _this = d as NormalizingNumericTextBox;
    _this.SelectedUnit = _this.Units.FirstOrDefault();
  }

  /// <inheritdoc />
  public override void OnApplyTemplate()
  {
    base.OnApplyTemplate();
    this.PART_UnitsItemsHost = GetTemplateChild("PART_UnitsItemsHost") as ItemsControl;

    if (this.PART_UnitsItemsHost == null)
    {
      throw new InvalidOperationException($"{nameof(this.PART_UnitsItemsHost)} not found in ControlTemplate");
    }

    this.PART_UnitsItemsHost.SetBinding(
      Selector.SelectedItemProperty,
      new Binding(nameof(this.SelectedUnit)) {Source = this});
    this.PART_UnitsItemsHost.SetBinding(
      ItemsControl.ItemsSourceProperty,
      new Binding(nameof(this.Units)) {Source = this});
    this.SelectedUnit = this.Units.FirstOrDefault();
  }

  #region Overrides of TextBoxBase

  /// <inheritdoc />
  protected override void OnTextChanged(TextChangedEventArgs e)
  {
    base.OnTextChanged(e);
    if (this.IsNormalizing)
    {
      return;
    }

    NormalizeNumericText();
  }

  /// <inheritdoc />
  protected override void OnTextInput(TextCompositionEventArgs e)
  {
    // Suppress non numeric characters
    if (!decimal.TryParse(e.Text, NumberStyles.Number, CultureInfo.CurrentCulture, out decimal _))
    {
      e.Handled = true;
      return;
    }

    base.OnTextInput(e);
  }

  #endregion Overrides of TextBoxBase

  private void NormalizeNumericText()
  {
    this.IsNormalizing = true;
    if (decimal.TryParse(this.Text, NumberStyles.Number, CultureInfo.CurrentCulture, out decimal numericValue))
    {
      this.NormalizedValue = numericValue * this.SelectedUnit.BaseFactor;
    }

    this.IsNormalizing = false;
  }

  private void ConvertNormalizedValueToNumericText()
  {
    this.IsNormalizing = true;
    decimal value = this.NormalizedValue / this.SelectedUnit.BaseFactor;
    this.Text = value.ToString(CultureInfo.CurrentCulture);
    this.IsNormalizing = false;
  }
}

Generic.xaml

<ResourceDictionary>

  <Style TargetType="NormalizingNumericTextBox">
    <Setter Property="BorderThickness" Value="1" />
    <Setter Property="BorderBrush" Value="DarkGray" />
    <Setter Property="HorizontalAlignment" Value="Left"/>
    <Setter Property="VerticalContentAlignment" Value="Center"/>
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="local:NormalizingNumericTextBox">
          <Border BorderBrush="{TemplateBinding BorderBrush}"
                  BorderThickness="{TemplateBinding BorderThickness}"
                  Background="{TemplateBinding Background}"
                  Padding="{TemplateBinding Padding}">
            <Grid>
              <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="Auto" />
              </Grid.ColumnDefinitions>
              <ScrollViewer x:Name="PART_ContentHost" Grid.Column="0" Margin="0" />
              <ComboBox x:Name="PART_UnitsItemsHost" Grid.Column="1" BorderThickness="0" HorizontalAlignment="Right" />
            </Grid>
          </Border>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>
</ResourceDictionary>

Upvotes: 1

Yogesh Wavhal
Yogesh Wavhal

Reputation: 60

I have not much idea about your code impact but I would suggest you try below design which uses MVVM Pattern which removes tight coupling between UI and Backend. I have separate out the things here

your XAML will have code like

    <TextBox x:Name="unitTextbox"
     Text="{Binding Path=Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
    </TextBox>

    <ComboBox IsSynchronizedWithCurrentItem="True"
      IsEditable="False"
      DisplayMemberPath="Name"
      SelectedItem="{Binding SelectedUnit}"
      ItemsSource="{Binding AvailableUnits}">
    </ComboBox>

Your ViewModel will be like

public class MainVm : Observable
{
    #region Private Fields
    private double _value;
    private ObservableCollection<Unit> _availableUnits;
    private Unit _selectedUnit;
    private Unit _previouslySelected;

    #endregion Private Fields

    #region Public Constructors

    public MainVm()
    {
        _availableUnits = new ObservableCollection<Unit>()
        {
          new Unit("mm²"),
          new Unit("cm²"),
          new Unit("dm²"),
          new Unit("m²")
        };
    }

    #endregion Public Constructors

    #region Public Properties

    public double Value
    {
        get
        {
            return _value;
        }
        set
        {
            if (_value != value)
            {
                _value = value;
                OnPropertyChanged();
            }
        }
    }

    public Unit SelectedUnit
    {
        get { return _selectedUnit; }
        set
        {

           _previouslySelected = _selectedUnit;
           _selectedUnit = value;
          // call to value conversion function
          // convert cm² to mm² or anything
           Value = UnitConvertor.Convert(_value, _previouslySelected.Name, _selectedUnit.Name);
           OnPropertyChanged();
        }
    }

    public ObservableCollection<Unit> AvailableUnits => _availableUnits;

    #endregion Public Properties
}

My Observable class will be like

 public class Observable : INotifyPropertyChanged
{
    #region Public Events

    public event PropertyChangedEventHandler PropertyChanged;

    #endregion Public Events

    #region Protected Methods

    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        var handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    #endregion Protected Methods
}

better to use an enum for units

Upvotes: 0

Related Questions