One of the problems with running Windows programs on a mobile device is that unless the program is aware of the higher DPI, it will show the text in a very small font. If it goes too far, the font becomes too hard to read.
The screen shot above is from my test Samsung Galaxy SIII. The size you see is about the same as the size on the mobile device if you are using a Windows desktop to view this post. The SIII specs specify that the pixel resolution is 1280×720. This fairly close to a standard desktop on a PC but on a much smaller device. On Windows, the standard dots per inch is 96 DPI. On high end mobile devices (think retina display) it is often 300+ DPI. By rule of thumb, text takes up around 9 times less area on a mobile device with high DPI than a standard Windows desktop. This difference cannot be ignored. Consequences include poor readability and not being able to select items like buttons, menus, and lists.
The obvious solution is to make all the fonts bigger. With Windows Presentation Framework (WPF), it is fairly easy to change how the application looks. Unfortunately, this is not as easy to accomplish in WinForms.
When looking at this problem, originally it seemed possible to convince WinForms that the DPI had changed. However, this only works if the DPI for Windows itself changes. The problem is that the remote device does not define the DPI for the Windows session. It might be possible to support this but it would most likely take a major effort from Citrix and Microsoft to support a native DPI that matches the device. There is another way.
Many months ago I worked on a simple WinForms sample that also prototyped a way to handle fonts. The idea was to get the real DPI from the device and then determine the multiplier between the device and the desktop. When the text controls are displayed, the new size is based on the normal size times the multiplier. This was better than doing nothing but it seemed a bit complicated. It was hard to track the original size after the size had changed. Also, only focusing on scaling did not address the need to reflow the content when the orientation changed. The sample is located on the Docs web site if you are curious. The class is called ScaleForm that supported doing this. At this point, I would not recommend doing it this way. Besides the issues, it does not use CMP.NET which is the preferred way of supporting C#/.Net. It is using the COM Interop interface which is a bit more raw. This sample was the basis of what was later learned during the building of the sample called User Info.
During the development of this new sample, a number of things became clear.
- The font needed to grow along with the control
- The size of the font was now limited by the area reserved for the text
- Since the text size cannot be predetermined for a given size, an API needs to be called to calculate the size.
- Having the font too big was just as bad as being too small.
- A range of possible sizes needed to be determined to restrict the font size.
- Needed a way to place the text based on a template layout.
Default text control sizes are going to get you no where. Mobile devices always have a higher DPI and therefore the text is always going to shrink. The real question becomes how much it should grow.
When I first started working on this sample, I made some assumptions that turned out to be wrong in the end. I assumed that I could just specify text locations in a fixed way with a fixed size. It seemed like a good idea to use the existing text control. It seemed a bit more pure this way. Later on it became obvious that the layout was suffering based on limitations of this assumption. WinForms controls are not always well suited for resizing and repositioning for a mobile device. It does not look as good as it should. This is a reflection of the text controls since they were not intended to be used this way. The end result is that the sample now uses a refactored control technique. Values from existing controls are reformatted into panels with multiple text lines. If the values change, the panels are redone.
Original format:
New format:
It is not possible to get this kind of clean layout without combining the content together. In this case, the six text boxes are combined into three labels with one label per line. It is a simpler result and it looks better too. If you are wondering why it had six fields, this was a direct result of this information coming from Active Directory in six properties.
It would be much easier to point to the code than to describe it here. However, it is not ready to be shared yet so that will have to wait.
Meanwhile, I will share sections of the code to give you an idea of how this works.
/// <summary> /// Adjust the font to fit in the specified cell /// </summary> /// <param name="control"></param> /// <param name="size"></param> /// <param name="cell"></param> public static Size AdjustFont(Control control, Size size, CellLayout layout, String[] textArray, FontRange minmaxFontHeight) { Size textsize = new Size(); Font font = null; float bestFit = GetBestFontFit(size, layout, textArray, minmaxFontHeight, out textsize, out font); if (!AreFontsSame(font, control.Font)) { control.Font = font; } return (textsize); } /// <summary> /// Determine the best font match for the size/layout/textArray /// </summary> /// <param name="size"></param> /// <param name="layout"></param> /// <param name="textArray"></param> /// <param name="minmax"></param> /// <returns></returns> public static float GetBestFontFit(Size size, CellLayout layout, String[] textArray, FontRange minmax, out Size textsize, out Font font) { float fontPixels = minmax.MaxHeight; float minfontPixels = minmax.MinHeight; int borderSize = (layout.BorderStyle != BorderStyle.None) ? 4 : 0; // find the corresponding font size for the textbox size font = new Font(layout.FontFamily, fontPixels, layout.FontStyle, GraphicsUnit.Pixel); textsize = GetMaximumMeasuredText(textArray, font); // having the font match the control exactly does not allow for a border so we give it a buffer of four pixels while ((((textsize.Height + borderSize) > size.Height) || ((textsize.Width + borderSize) > size.Width)) && (fontPixels > minfontPixels)) { fontPixels -= 1.0F; // drop the old one font.Dispose(); font = new Font(font.FontFamily, fontPixels, font.Style, GraphicsUnit.Pixel); textsize = GetMaximumMeasuredText(textArray, font); } return (fontPixels); } /// <summary> /// Are the two specified fonts the same? /// </summary> /// <param name="font1"></param> /// <param name="font2"></param> /// <returns></returns> private static bool AreFontsSame(Font font1, Font font2) { bool SameFont = false; if ((font1.FontFamily.Name == font2.FontFamily.Name) && (font1.Size == font2.Size) && (font1.Style == font2.Style)) { SameFont = true; } return (SameFont); } /// <summary> /// Determine the lines that have the biggest height and width /// </summary> /// <param name="listbox"></param> /// <returns></returns> public static Size GetMaximumMeasuredText(String[] textArray, Font font) { Size maxSize = new Size(0, 0); Size fontSize; foreach (String text in textArray) { String fontText = text; if (fontText == "") { // cannot measure an empty string so we add in our own string to try. fontText = "Mop"; } fontSize = TextRenderer.MeasureText(fontText, font); if (fontSize.Height > maxSize.Height) { maxSize.Height = fontSize.Height; } if (fontSize.Width > maxSize.Width) { maxSize.Width = fontSize.Width; } } if ((maxSize.Height == 0) && (maxSize.Width == 0)) { Trace.WriteLine("maxSize is (0,0) when it should not be set that."); } return (maxSize); }
In English, this code is fitting the text into the size of a cell. The cell size is an invented concept for containing a control. The text will fit into the control which fits inside the cell size determined by the layout. The minimum and maximum font heights are determined before calling this code. The cornerstone of this code is TextRenderer.MeasureText which can determine the size of the text specified without having to render it. This permits getting a perfect fit after a few attempts within the min/max range. The functions are setup to being able to get the maximum size of the text for multiple lines. This is ideal for fitting the content into the panel.
There is much more to explain about the layout and placement of the controls so that will be continued in the next post. However, it is important to explain the basics now. The model in the sample is to have a Layout and a Placement class. The Layout class is responsible for the arrangement of the controls in an abstract table format. It does not have any precise locations but is instead based on different measurements that will be resolved only in the Placement class.
The model is to use a row and cell system. Multiple cells can exist on one row. Rows are not limited but it is important to consider how things will be arranged on the screen at once. Each cell has a number of properties which control how the cell is used. The model evolved from trying to find something that would work well. It is not as comprehensive as Microsoft’s WPF support but it does address the basics of what WinForms needs to support a mobile device. The best part for me has been that I can modify the nature of how it works based on new requirements. The same will be true for you when you can get the download of the sample.
Here is the entire function that encapsulates the layout of the User Info program for a mobile device.
/// <summary> /// Create the template which is used to place the controls later on /// </summary> private ControlTable GenerateUITemplate(ControlTableTemplate template) { ControlTable UserControlTable; FontFamily fontFamily = new FontFamily("Tahoma"); double multiplier = GetDeviceMultiplier(); CellMeasure ThumbnailMeasure = new CellMeasure(1.5 * multiplier, Units.Centimeter); CellSize ThumbnailSize = new CellSize(ThumbnailMeasure, ThumbnailMeasure); CellMeasure BioMeasureWidth = new CellMeasure(100, Units.Percent, Op.Subtract, ThumbnailMeasure); CellSize BioSize = new CellSize(BioMeasureWidth, ThumbnailMeasure); CellMeasure IconMeasure = new CellMeasure(16 * multiplier, Units.Point); CellMeasure AddressMeasure = new CellMeasure(10 * multiplier, Units.Point); CellSize IconSize = new CellSize(IconMeasure, IconMeasure); CellMeasure ContactMeasureWidth = new CellMeasure(100, Units.Percent, Op.Subtract, IconMeasure); CellSize ContactSize = new CellSize(ContactMeasureWidth, IconMeasure); CellMeasure HeightMeasure = new CellMeasure(100, Units.Percent); CellMeasure WidthMeasure = new CellMeasure(100, Units.Percent); CellMeasure HalfWidthMeasure = new CellMeasure(50, Units.Percent); CellMeasure QuarterWidthMeasure = new CellMeasure(25, Units.Percent); CellMeasure BorderHeightMeasure = new CellMeasure(2, Units.Pixel); CellSize BorderBoxSize = new CellSize(WidthMeasure, BorderHeightMeasure); CellSize TextBoxSize = new CellSize(WidthMeasure, IconMeasure); CellMeasure AssociatesLineHeight = new CellMeasure(12 * multiplier, Units.Point); CellMeasure NonWorkAreaHeight = new CellMeasure(BorderHeightMeasure, Op.Add, IconMeasure, Op.Add, ThumbnailMeasure); CellMeasure WorkAreaHeight = new CellMeasure(HeightMeasure, Op.Subtract, NonWorkAreaHeight ); CellSize WorkAreaBoxSize = new CellSize(WidthMeasure, WorkAreaHeight); LineSize AssociatesLineSize = new LineSize(WidthMeasure, AssociatesLineHeight); CellMeasure OneFifthWorkAreaHeight = new CellMeasure(WorkAreaHeight, Op.Divide, new CellMeasure(5.0, Units.Scalar)); CellMeasure OneHalfWorkAreaHeight = new CellMeasure(WorkAreaHeight, Op.Divide, new CellMeasure(2.0, Units.Scalar)); CellSize AddressPanelBoxMinSize = new CellSize(WidthMeasure, OneFifthWorkAreaHeight); CellSize AddressPanelBoxMaxSize = new CellSize(WidthMeasure, OneHalfWorkAreaHeight); CellMeasure BioLineHeight = new CellMeasure(ThumbnailMeasure.Measurement / 4.0, ThumbnailMeasure.Unit); LineSize BioLine = new LineSize(BioMeasureWidth, BioLineHeight); LineSize IconLine = new LineSize(IconSize); CellColors Transparent = new CellColors(this.ForeColor, this.BackColor); CellColors BlackWhite = new CellColors(); CellColors BlackGrey = new CellColors(Color.Black, Color.WhiteSmoke); CellColors BlackBlack = new CellColors(Color.Black, Color.Black); CellColors BlackAntiqueWhite = new CellColors(Color.Black, Color.AntiqueWhite); CellColors BlackKhaki = new CellColors(Color.Black, Color.Khaki); CellColors BlackSilver = new CellColors(Color.Black, Color.Silver); CellColors BlackIndianRed = new CellColors(Color.Black, Color.IndianRed); CellPadding Padding4px = new CellPadding(4); CellPadding Padding2px = new CellPadding(2); CellPadding Padding3pxH = new CellPadding(3,0,3,0); CellPadding Padding1px = new CellPadding(1); CellPadding Padding0px = new CellPadding(0); CellLayout ThumbnailBox = new CellLayout(ThumbnailSize, BlackAntiqueWhite, fontFamily, FontStyle.Regular, BorderStyle.None, Padding0px); CellLayout BioBox = new CellLayout(BioSize, BioLine, BlackAntiqueWhite, fontFamily, FontStyle.Regular, BorderStyle.None, Padding3pxH); CellLayout ButtonBox = new CellLayout(IconSize, IconLine, BlackAntiqueWhite, fontFamily, FontStyle.Regular, BorderStyle.None, Padding0px); CellLayout ContactBox = new CellLayout(IconSize, Transparent, fontFamily, FontStyle.Regular, BorderStyle.None, Padding1px); CellLayout ContactTextBox = new CellLayout(ContactSize, Transparent, fontFamily, FontStyle.Regular, BorderStyle.None, Padding4px); CellLayout UserTextBox = new CellLayout(TextBoxSize, Transparent, fontFamily, FontStyle.Regular, BorderStyle.None, Padding4px); CellLayout AssociatesPanelBox = new CellLayout(WorkAreaBoxSize, AssociatesLineSize, BlackSilver, fontFamily, FontStyle.Regular, BorderStyle.None, Padding0px, CellSizing.Variable); CellLayout WorkAreaPanelBox = new CellLayout(WorkAreaBoxSize, AssociatesLineSize, Transparent, fontFamily, FontStyle.Regular, BorderStyle.None, Padding0px); CellLayout BorderBox = new CellLayout(BorderBoxSize, BlackBlack, fontFamily, FontStyle.Regular, BorderStyle.None, Padding0px); CellLayout AddressPanelTextBox = new CellLayout(AddressPanelBoxMaxSize, AssociatesLineSize, BlackKhaki, fontFamily, FontStyle.Regular, BorderStyle.None, Padding0px, CellSizing.Variable); CellLayout PhonePanelBox = new CellLayout(AddressPanelBoxMaxSize, AssociatesLineSize, BlackIndianRed, fontFamily, FontStyle.Regular, BorderStyle.None, Padding0px, CellSizing.Variable); UserControlTable = new ControlTable(); if (template == ControlTableTemplate.TopWindow) { UserControlTable.AddRow(new ControlRow(RowNames.Thumbnail, new ControlCell(CellNames.Thumbnail, Thumbnail, ThumbnailBox, CellHasData.Present, CellType.Image), new ControlCell(new Control[] { FullName, Title, Department, Company }, BioBox, LayoutDirection.Vertical, CellHasData.Present, CellType.Multiple), LayoutRelative.ContainerTopLeft, CheckValue.Perform)); UserControlTable.AddRow(new ControlRow(RowNames.Border, new ControlCell(CellNames.Border, BorderLine, BorderBox, CellHasData.None, CellType.Generic), LayoutRelative.RowBelow, CheckValue.Ignore)); ControlCell.CalculateSizeHandler userDatasizeHandler = new ControlCell.CalculateSizeHandler(CalculateUserDataPanelSize); UserControlTable.AddRow(new ControlRow(RowNames.UserData, new ControlCell(CellNames.UserData, UserDataPanel, WorkAreaPanelBox, CellHasData.Present, CellType.UserData, userDatasizeHandler), LayoutRelative.RowBelow, CheckValue.Ignore)); ControlCell[] cells = new ControlCell[9]; cells[0] = new ControlCell(CellNames.Home, HomeButton, ButtonBox, CellHasData.None, CellType.Button); cells[1] = new ControlCell(CellNames.Back, BackButton, ButtonBox, CellHasData.None, CellType.Button); cells[2] = new ControlCell(CellNames.Forward, ForwardButton, ButtonBox, CellHasData.None, CellType.Button); cells[3] = new ControlCell(CellNames.Search, SearchButton, ButtonBox, CellHasData.None, CellType.Button); cells[4] = new ControlCell(CellNames.Phone, WorkPhoneButton, ButtonBox, CellHasData.None, CellType.Button); cells[5] = new ControlCell(CellNames.Address, AddressButton, ButtonBox, CellHasData.None, CellType.Button); cells[6] = new ControlCell(CellNames.Associates, PeersButton, ButtonBox, CellHasData.None, CellType.Button); cells[7] = new ControlCell(CellNames.Edit, EditButton, ButtonBox, CellHasData.None, CellType.Button); cells[8] = new ControlCell(CellNames.Off, OffButton, ButtonBox, CellHasData.None, CellType.Button); CellLayoutSettings buttonLayoutSettings = new CellLayoutSettings(LayoutRelative.ContainerBottomLeft, LayoutDirection.Horizontal, LayoutSpacing.EqualHorizontal, LayoutZOrder.Front); UserControlTable.AddRow(new ControlRow(RowNames.Buttons, cells, buttonLayoutSettings, CheckValue.Ignore)); ControlGroup Header = new ControlGroup(GroupNames.Header, new String[] { RowNames.Thumbnail, RowNames.Border }, UserControlTable, GroupTypes.Header); ControlGroup Footer = new ControlGroup(GroupNames.Footer, new String[] { RowNames.Buttons }, UserControlTable, GroupTypes.Footer); UserControlTable.AddGroup(Header); UserControlTable.AddGroup(Footer); } else if (template == ControlTableTemplate.UserData) { // phone group ControlCell.CalculateSizeHandler phoneSizeHandler = new ControlCell.CalculateSizeHandler(CalculatePhonePanelSize); UserControlTable.AddRow(new ControlRow(RowNames.ContactDetails, new ControlCell(CellNames.PhonePanel, PhonePanel, PhonePanelBox, CellHasData.Present, CellType.Associates, phoneSizeHandler), LayoutRelative.RowBelow, CheckValue.Perform)); UserControlTable.AddRow(new ControlRow(RowNames.Border2, new ControlCell(CellNames.Border, BorderLine2, BorderBox, CellHasData.None, CellType.Generic), LayoutRelative.RowBelow, CheckValue.Ignore)); // address group ControlCell.CalculateSizeHandler addressSizeHandler = new ControlCell.CalculateSizeHandler(CalculateAddressPanelSize); UserControlTable.AddRow(new ControlRow(RowNames.FullAddress, new ControlCell(CellNames.AddressPanel, AddressPanel, AddressPanelTextBox, CellHasData.Present, CellType.FullAddress, addressSizeHandler), LayoutRelative.RowBelow, CheckValue.Perform)); UserControlTable.AddRow(new ControlRow(RowNames.Border3, new ControlCell(CellNames.Border, BorderLine3, BorderBox, CellHasData.None, CellType.Generic), LayoutRelative.RowBelow, CheckValue.Ignore)); // associates group ControlCell.CalculateSizeHandler associatesSizeHandler = new ControlCell.CalculateSizeHandler(CalculateAssociatesPanelSize); UserControlTable.AddRow(new ControlRow(RowNames.Associates, new ControlCell(CellNames.Associates, AssociatesPanel, AssociatesPanelBox, CellHasData.Present, CellType.Associates, associatesSizeHandler), LayoutRelative.RowBelow, CheckValue.Perform)); UserControlTable.AddRow(new ControlRow(RowNames.Border4, new ControlCell(CellNames.Border, BorderLine4, BorderBox, CellHasData.None, CellType.Generic), LayoutRelative.RowBelow, CheckValue.Ignore)); // put the rows in groups ControlGroup PhoneGroup = new ControlGroup(GroupNames.Phone, new String[] { RowNames.WorkPhone, RowNames.HomePhone, RowNames.MobilePhone, RowNames.Email, RowNames.Border2 }, UserControlTable, GroupTypes.Phone); ControlGroup Associates = new ControlGroup(GroupNames.Associates, new String[] { RowNames.Associates }, UserControlTable, GroupTypes.Associates); ControlGroup AddressGroup = new ControlGroup(GroupNames.Address, new String[] { RowNames.FullAddress, RowNames.Border3 }, UserControlTable, GroupTypes.Address); // add the groups into the table UserControlTable.AddGroup(PhoneGroup); UserControlTable.AddGroup(Associates); UserControlTable.AddGroup(AddressGroup); } else { Trace.Assert(false); } return (UserControlTable); }
Do not worry about the details too much. I just wanted to give you a taste of the layout code. It uses a number of other classes that support having rows and cells. It also refers to CellMeasure which is a class for specifying sizes in different units. Essentially it is a code version of something that could potentially could be done in HTML or XML. It is not as complicated as it first appears. Once you learn a row, then it is easy to do another.
Knowing this, it is also important to point out that this one function is generating two different control tables. The first one (Top window) is the outer window that contains the header and footer (user bio and buttons). In the middle, a scrollable region exists using a viewer and scrolling panel. The scrolling panel uses the second control table. In this table, it only has three panels which correspond to the contact details, the office information, and the related employees. The panels are attached to the scrollable panel based on the second control table. The size is determined for each panel by using a callback mechanism. The reason why it has a callback is that the content is not known during the generation of the template. Later on, the Placement class will formalize the sizes and use the callback. It allows the panels to be the right sizes based on the content.
Like mentioned in a previous post, the Layout/Placement code can support relocation of existing controls or refactoring them. Originally it was intended to relocate only. This was not practical. However, the relocation is still happening for some controls like the ones in the bio and the picture.
Even though there is more to describe, this is enough for this blog post. See you next time.