mirror of
https://gitlab.acidiclight.dev/sociallydistant/sociallydistant.git
synced 2025-01-22 09:31:47 -05:00
Move documentation from GitLab wiki into docfx
This commit is contained in:
parent
f58170f700
commit
a2bff3fd46
6 changed files with 840 additions and 1 deletions
117
docs/framework/event-bus.md
Normal file
117
docs/framework/event-bus.md
Normal file
|
@ -0,0 +1,117 @@
|
|||
# event Bus
|
||||
Socially Distant has a bunch of moving parts that make up its game play. The game is split into three main parts - the UI, the game hypervisor, and the network simulation. These three layers need to stay decoupled from each other, but they do need to communicate with each other. In some cases, they need to pass messages along to each other across threads. The Event Bus facilitates all of that.
|
||||
|
||||
## Posting an Event
|
||||
|
||||
When you want to tell the rest of the game that your code did something, you do so by posting a new Event to the Event Bus.
|
||||
|
||||
Consider this example.
|
||||
|
||||
You want to fire an event every time a command is executed on a computer.
|
||||
|
||||
First, create a new `CommandRunEvent` class inheriting `DeviceEvent`.
|
||||
|
||||
```csharp
|
||||
public sealed class CommandRunEvent : DeviceEvent
|
||||
{
|
||||
public string Name { get;
|
||||
public string[] Arguments { get; }
|
||||
|
||||
public CommandRunEvent(IComputer computer, string name, string[] args) : base(computer)
|
||||
{
|
||||
Name = name;
|
||||
Arguments = args;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then, simply create an instance of the `CommandRunEvent` and then:
|
||||
|
||||
```csharp
|
||||
EventBus.Post(myCommandRunEvent);
|
||||
```
|
||||
|
||||
It's that simple!
|
||||
|
||||
## Receiving Events
|
||||
|
||||
Most of the time, you'll be listening to events on the bus.
|
||||
|
||||
To listen for an event, call `EventBus.Listen<T>(callback)`.
|
||||
|
||||
`IDisposable EventBus.Listen<T>(Action<T> callback)` (where `T` is any `Event`)
|
||||
|
||||
This method takes a callback, and a type of Socially Distant event. It creates an event listener that calls the callback any time an event of matching type is posted. The event listener is returned as an `IDisposable`. When you want to stop receiving events, call `Dispose()` on the event listener.
|
||||
|
||||
Here's an example of how to receive an event every time a device is pinged on the Internet.
|
||||
|
||||
```csharp
|
||||
private void OnPing(PingEvent pingEvent)
|
||||
{
|
||||
Log.Information("Pong!");
|
||||
}
|
||||
|
||||
using var listener = EventBus.Listen<PingEvent>(OnPing);
|
||||
|
||||
// ...
|
||||
|
||||
listener.Dispose();
|
||||
```
|
||||
|
||||
## What are Events?
|
||||
|
||||
An Event is just a plain C# object containing data about something that just happened in the game. Any time something happens in-game, an Event is posted to the Event Bus containing information about what just happened.
|
||||
|
||||
For example, any time the files on an in-game computer are accessed, the game posts a `FileSystemEvent` object to the Event Bus. In this case, information about what computer was accessed and what happened on disk is sent with the event. Other parts of the game, like the mission system, can then listen for `FileSystemEvent` events on the Event Bus, and do what they will with the information. This can be anything, like completing an objective when a specific file is deleted.
|
||||
|
||||
## Event Hierarchy
|
||||
|
||||
Any object inheriting from `Event` can be posted to the event bus. You can also inherit subclasses of `Event`. This forms the Event Hierarchy.
|
||||
|
||||
Consider the following example situation:
|
||||
|
||||
The Mission System fires three kinds of events.
|
||||
|
||||
* `MissionFailEvent`: when a mission is failed.
|
||||
* `MissionCompletedEvent`: when a mission is completed.
|
||||
* `MissionAbandonedEvent`: When a mission is abandoned.
|
||||
|
||||
These three events may have unique information about what specifically happened. A fail event may carry info about why the mission failed. However, all mission events carry a mission with them.
|
||||
|
||||
Another part of the game, like `DocumentAdapter`, may want to receive all mission events so it can redraw any mission info boxes in an email. Without Event Hierarchy, `DocumentAdapter` would need to listen for mission fails, completions, and abandons. This works fine until we add a `MissionStartEvent`!
|
||||
|
||||
```csharp
|
||||
EventBus.Listen<MissionFailEvent>(OnMissionFail);
|
||||
EventBus.Listen<MissionCompleteEvent>(OnMissionComplete);
|
||||
EventBus.Listen<MissionAbandonEvent>(OnMissionAbandon);
|
||||
```
|
||||
|
||||
Since we know all types of mission event will have a mission associated with them, we can define a common `MissionEvent` class they all inherit from.
|
||||
|
||||
```csharp
|
||||
public abstract class MissionEvent : Event
|
||||
{
|
||||
public IMission Mission { get; }
|
||||
|
||||
protected MissionEvent(IMission mission)
|
||||
{
|
||||
Mission = mission;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then, `DocumentAdapter` can simplify to just listening for mission events.
|
||||
|
||||
```csharp
|
||||
EventBus.Listen<MissionEvent>(OnMissionEvent);
|
||||
```
|
||||
|
||||
You can use C#'s pattern matching features to see more specific information about an `Event` instance, including checking what kind of event it is.
|
||||
|
||||
## Thread-safety and performance notes
|
||||
|
||||
Posting an event with `EventBus.Post()` is thread-safe. Events are posted to a `ConcurrentQueue` to be dispatched on the next available frame.
|
||||
|
||||
All events on the Event Bus are dispatched on the main UI thread. Do not do slow work in an event listener.
|
||||
|
||||
The game only dispatches 129 `Event` instances per frame, due to how clogged the event bus can get when the player's moving fast.
|
72
docs/framework/playing-audio.md
Normal file
72
docs/framework/playing-audio.md
Normal file
|
@ -0,0 +1,72 @@
|
|||
# Playing Audio
|
||||
Sometimes, you want to play sound effects and music inside the game. Here's how.
|
||||
|
||||
## Adding audio files to the game
|
||||
|
||||
If you're contributing to the base game, you can add audio files to the game by placing them anywhere in the `Content` folder inside the `SociallyDistant` project. At this time, only WAV files with a sample rate at or below 48000Hz are supported.
|
||||
|
||||
Make sure to keep things organized, and to name the audio file something concise and descriptive. For example, for a Hollywood-style data reading sound effect, you could place it in `Content/Audio/SFX/DataRead.wav`.
|
||||
|
||||
### Where to place audio files
|
||||
|
||||
Audio files should be placed under the `Content/Audio` directory. Sound effects should be placed under the `SFX` sub-folder, and songs under the `BGM` folder.
|
||||
|
||||
### Dealing with licensed sound effects
|
||||
|
||||
Do not commit licensed sound effects or other licensed assets to a public branch or repo. Doing so is subject to deletion from this GitLab instance without warning, as distributing source assets is generally against most audio and music licenses.
|
||||
|
||||
Instead, you should keep these assets in a separate, **private** repository similar to how Career Mode is maintained. Inside that private repo, you should create a dedicated branch to store licensed assets in. We will not accept merge requests from branches containing licensed assets.
|
||||
|
||||
## Playing Music
|
||||
|
||||
Now for the actually fun part. If you've added a song to the game, or have an existing song you want the game to play, here's how.
|
||||
|
||||
### Using Event Bus
|
||||
|
||||
You can post a `PlaySongEvent` to the game's [Event Bus](event-bus). The `PlaySongEvent` accepts a resource path and an optional boolean indicating whether the song should loop. Songs loop by default.
|
||||
|
||||
```csharp
|
||||
EventBus.Post(new PlaySongEvent("/Core/Career/Audio/BGM/PsyOp"));
|
||||
```
|
||||
|
||||
When specifying the resource path, do not include the file extension of the audio file.
|
||||
|
||||
### Using `sdsh` scripts
|
||||
|
||||
Use the `bgm` command, specifying the same style of resource path as above. This command only works in mission scripts.
|
||||
|
||||
```bash
|
||||
bgm /Core/Career/Audio/BGM/PsyOp
|
||||
```
|
||||
|
||||
Using the `bgm` command changes the **default** background music of the game, meaning that the same song will play when the current user logs back in.
|
||||
|
||||
### By modifying Protected World State
|
||||
|
||||
If you are contributing directly to the `SociallyDistant` project, you can modify the `DefaultBackgroundMusicPath` value in the world's protected state. This cannot be done in mods. This is what the `bgm` command does behind the scenes.
|
||||
|
||||
## Playing Sounds
|
||||
|
||||
You will more than likely want to play sound effects rather than music. There are two ways to play sound effects.
|
||||
|
||||
### Using the Event Bus
|
||||
|
||||
You can post a `PlaySoundEvent` to the Event Bus to play sound effects. Like `PlaySongEvent`, the `PlaySoundEvent` constructor accepts a resource path.
|
||||
|
||||
```csharp
|
||||
EventBus.Post(new PlaySoundEvent("/Core/Audio/SFX/DataRead"));
|
||||
```
|
||||
|
||||
### Using the SoundScheme API
|
||||
|
||||
Most common interface sounds can be played by using the `SoundScheme.PlayGuiSound(GuiSoundName)` static method. You should prefer this over `PlaySoundEvent` when playing common interface sounds, since the resource paths are managed by the game itself. This also allows the player to change the sound scheme.
|
||||
|
||||
```csharp
|
||||
SoundScheme.PlayGuiSound(GuiSoundName.Bell);
|
||||
```
|
||||
|
||||
## Missing audio files don't crash the game
|
||||
|
||||
Attempting to play a missing song or sound effect **will not cause any kind of error.** This is by design, allowing the game code to _request_ to play licensed sounds without the assets actually being present.
|
||||
|
||||
If you attempt to play a missing sound or one that can't load, the game will log a warning and simply play nothing.
|
16
docs/toc.yml
16
docs/toc.yml
|
@ -12,4 +12,18 @@ items:
|
|||
- name: Game scripting (sdsh)
|
||||
items:
|
||||
- name: The Basics
|
||||
href: sdsh/the-basics.md
|
||||
href: sdsh/the-basics.md
|
||||
- name: Game Framework
|
||||
items:
|
||||
- name: Event Bus
|
||||
href: framework/event-bus.md
|
||||
- name: Playing Audio
|
||||
href: framework/playing-audio.md
|
||||
- name: User Interface
|
||||
items:
|
||||
- name: UI Overview
|
||||
href: ui/overview.md
|
||||
- name: List Adapters
|
||||
href: ui/list-adapters.md
|
||||
- name: Optimizing UI for Performance
|
||||
href: ui/optimization.md
|
202
docs/ui/list-adapters.md
Normal file
202
docs/ui/list-adapters.md
Normal file
|
@ -0,0 +1,202 @@
|
|||
# List Adapters
|
||||
A `ListAdapter` is a tool in the game's UI system that allows you to display a list of widgets based on a common data model. For example, the System Settings category list and the settings themselves are both `ListAdapter`s.
|
||||
|
||||
You can use a `ListAdapter` to display any kind of data inside any kind of widget deriving from `ContainerWidget`. If you are familiar with [Optimized ScrollView Adapter](https://assetstore.unity.com/packages/tools/gui/optimized-scrollview-adapter-68436), the API is very similar. However, a `ListAdapter` doesn't need to be a scroll view.
|
||||
|
||||
## Creating your own ListAdapter
|
||||
Let's create a `ListAdapter` that can display a list of cracked user credentials.
|
||||
|
||||
First, create the data model itself
|
||||
|
||||
```cs
|
||||
public struct CrackedPassword
|
||||
{
|
||||
public string FullName;
|
||||
public string UserName;
|
||||
public string Password;
|
||||
}
|
||||
```
|
||||
|
||||
And its list - it can be anything implementing `Ienumerable<CrackedPassword>`:
|
||||
|
||||
```cs
|
||||
var data = new CrackedPassword[] {
|
||||
new CrackedPassword { FullName = "Brodie Robertson", UserName = "brodieonlinux", Password = "wayland shill" },
|
||||
new CrackedPassword { FullName = "Ritchie Frodomar", UserName = "ritchie", Password = "strong and complicated password" },
|
||||
new CrackedPassword { FullName = "TheEvilSkeleton", UserName = "tesk", Password = "bottleofwine" }
|
||||
};
|
||||
```
|
||||
|
||||
Next, create a `Widget` class that can display a `CrackedPassword`:
|
||||
|
||||
```csharp
|
||||
public sealed class CrackedPasswordView : Widget
|
||||
{
|
||||
private readonly StackPanel stack = new StackPanel();
|
||||
private readonly TextWidget fullName = new TextWidget();
|
||||
private readonly TextWidget username = new TextWidget();
|
||||
private readonly TextWidget password = new TextWidget();
|
||||
|
||||
public CrackedPasswordView()
|
||||
{
|
||||
Children.Add(stack);
|
||||
|
||||
stack.ChildWidgets.Add(fullName);
|
||||
stack.ChildWidgets.Add(username);
|
||||
stack.ChildWidgets.Add(password);
|
||||
}
|
||||
|
||||
public void UpdateView(CrackedPassword data)
|
||||
{
|
||||
fullName.Text = data.FullName;
|
||||
username.Text = data.UserName;
|
||||
password.Text = data.Password;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The important part is the `UpdateModel(CrackedPassword)` method - this is what you will call when it's time to update a widget with new data.
|
||||
|
||||
Now that we've defined the widget, it's time to define its `ViewHolder`. A `ViewHolder` acts as the "slot" for the widget type we just created, within the `ListAdapter`. There will be a `ViewHolder` for each element in the data source.
|
||||
|
||||
```csharp
|
||||
public sealed class CrackedPasswordViewHolder : ViewHolder
|
||||
{
|
||||
private readonly CrackedPasswordView view = new();
|
||||
|
||||
public CrackedPasswordViewHolder(int itemIndex, Box root) : base(itemIndex, root)
|
||||
{
|
||||
root.Content = view;
|
||||
}
|
||||
|
||||
public void UpdateView(CrackedPassword data)
|
||||
{
|
||||
view.UpdateView(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Most `ViewHolder` objects will look identical to the code above. All a `ViewHolder` must do is create an instance of the view widget, assign it as the `Content` of a "root" widget, and expose any API of the view widget needed by your `ListAdapter` class.
|
||||
|
||||
We now have everything we need to create the `ListAdapter` itself. We will display our data inside a `StackPanel`.
|
||||
|
||||
```csharp
|
||||
public sealed class CrackedPasswordList : ListAdapter<StackPanel, CrackedPasswordViewHolder>
|
||||
{
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
The first type parameter of `ListAdapter` declares the type of `ContainerWidget` we want to display all of our list items in, in this case `StackPanel`. The second parameter declares the `ViewHolder`-deriving type we just created.
|
||||
|
||||
We must override two abstract methods to tell the `ListAdapter` how to interact with our data - `TViewHolder CreateViewHolder(int, Box)` and `void pdateViewHolder(TViewHolder)`.
|
||||
|
||||
The `CreateViewHolder` method just constructs a new `ViewHolder` instance of the required type and returns it. This method is called when a new view widget needs to be created. We are given the item index and root widget of the new view, so we can just pass them to the constructor of `CrackedPasswordViewHolder`.
|
||||
|
||||
```csharp
|
||||
public override CrackedPasswordViewHolder CreateViewHolder(int itemIndex, Box rootWidget)
|
||||
{
|
||||
return new CrackedPasswordViewHolder(itemIndex, rootWidget);
|
||||
}
|
||||
```
|
||||
|
||||
`UpdateViewHolder()` is called every time the data of a list item has changed. This method receives the item's view holder, and must retrieve the new data and update the view with it.
|
||||
|
||||
```csharp
|
||||
protected override void UpdateViewHolder(CrackedPasswordViewHolder viewHolder)
|
||||
{
|
||||
CrackedPassword item = items[viewHolder.ItemIndex];
|
||||
viewHolder.UpdateView(item);
|
||||
}
|
||||
```
|
||||
|
||||
The only problem we need to solve now is telling `ListAdapter` when our data changes, and being able to access it. This is what the `DataHelper<T>` class is for.
|
||||
|
||||
In the `CrackedPasswordListAdapter`, add a readonly field `items` of type `DataHelper<CrackedPassword>` and construct it.
|
||||
|
||||
```csharp
|
||||
private readonly DataHelper<CrackedPassword> items;
|
||||
|
||||
public CrackedPasswordList()
|
||||
{
|
||||
items = new DataHelper<CrackedPassword>(this);
|
||||
}
|
||||
```
|
||||
|
||||
We can now populate the ListAdapter with data by calling `items.SetItems()`.
|
||||
|
||||
```csharp
|
||||
var data = new CrackedPassword[] {
|
||||
new CrackedPassword { FullName = "Brodie Robertson", UserName = "brodieonlinux", Password = "wayland shill" },
|
||||
new CrackedPassword { FullName = "Ritchie Frodomar", UserName = "ritchie", Password = "strong and complicated password" },
|
||||
new CrackedPassword { FullName = "TheEvilSkeleton", UserName = "tesk", Password = "bottleofwine" }
|
||||
};
|
||||
|
||||
items.SetItems(data);
|
||||
```
|
||||
|
||||
## Examples of ListAdapter used in-game
|
||||
|
||||
**System Settings**
|
||||
|
||||
- the sidebar listing all settings categories
|
||||
- the scroll view of the active category, where all of its settings widgets are shown
|
||||
|
||||
**Desktop**
|
||||
|
||||
- the Info Panel (mission objectives and notifications)
|
||||
- the Dock (icon groups)
|
||||
- the Application Launcher (the grid view of icons)
|
||||
|
||||
That's by no means exhaustive, ListAdapter is used **everywhere.**
|
||||
|
||||
## Performance concerns
|
||||
ListAdapter tries its best to keep performance issues at bay, but it needs to work with what it's given. You should keep a few things in mind.
|
||||
|
||||
### Widget recycling
|
||||
When items are removed from a ListADapter, their view widgets are recycled. When new items are added to the list adapter, widgets are pulled from the recycle bin first. However, this does not extend to the child of the view widget. This is because ListAdapter can't guess how your custom widget handles state or memory.
|
||||
|
||||
If you'd like to allow `ListADapter` to recycle your custom widget, you will need to do some extra work.
|
||||
|
||||
Inside your `ViewHolder` constructor, replace:
|
||||
|
||||
```csharp
|
||||
view = new CrackedPasswordView();
|
||||
|
||||
// WITH
|
||||
|
||||
view = RecycleBin.Get<CrackedPasswordView>().GetWidget();
|
||||
```
|
||||
|
||||
This allows you to retrieve your `CrackedPasswordView` instance from the recycle bin. If there are no recyclable instances, a new instance will be created. This means you can only use the recycle bin if your widget has a public parameterless constructor.
|
||||
|
||||
Next, inside your `ViewHolder` class, add:
|
||||
|
||||
```csharp
|
||||
public void Recycle()
|
||||
{
|
||||
// NOTE: You cannot recycle a toplevel widget OR a widget that's added as a child to another.
|
||||
Root.Content = null;
|
||||
RecycleBin.Get<CrackedPasswordView>().Recycle(view);
|
||||
}
|
||||
```
|
||||
|
||||
This method puts your `CrackedPasswordView` instance in the recycle bin. You call this method when a list item is being removed from a `ListAdapter`. To get that working, override the following method in your `ListAdapter` class.
|
||||
|
||||
```csharp
|
||||
protected override void BeforeRemoveItem(CrackedPasswordViewHolder holder)
|
||||
{
|
||||
holder.Recycle();
|
||||
}
|
||||
```
|
||||
|
||||
> :warning: **Warning**
|
||||
>
|
||||
> Before recycling a widget, make sure you remove it from its parent and unbind all event/callback delegates. You do not need to release any unmanaged resources, since the idea is to re-use them when the widget itself is re-used. Recycling is not the same as disposal.
|
||||
|
||||
> :asterisk: **Note**
|
||||
>
|
||||
> The Recycle Bin is a shared resource. When calling `RecycleBin.Get<T>()`, you are retrieving the shared recycle bin for that exact type of widget. You are not retrieving a new recycle bin instance unless that widget type has never been recycled before.
|
||||
|
||||
### Layout and geometry updates
|
||||
When data in a `ListAdapter` changes, so too shall its widget layout and geometry. Only notify `ListAdapter` of data changes when you know the data has changed.
|
105
docs/ui/optimization.md
Normal file
105
docs/ui/optimization.md
Normal file
|
@ -0,0 +1,105 @@
|
|||
# Optimizing UI for Performance
|
||||
|
||||
Socially Distant uses UI everywhere. That being said, it's still a game that really benefits from a stable framereate. We're also targeting a wide range of hardware, including systems with relatively low amounts of RAM and lower-end GPUs. All that to say, we should try to keep the UI well-optimized. Here are some ways you can do so.
|
||||
|
||||
## Understanding how the UI system does things
|
||||
|
||||
Every frame, the UI system does the following things in order.
|
||||
|
||||
1. **Process input events:** The UI system receives input events from the mouse/keyboard/rest of the game, and propagates them to the widgets that need to receive them. The widgets then modify their internal state accordingly in response.
|
||||
2. **Layout Pass**: The UI system instructs all top-levels (widgets with no parents) to update their layout. This action is recursive, eventually every single widget on-screen will have its layout updated.
|
||||
1. **Measurement Pass**: All parent widgets will calculate their desired size by calculating their child/content sizes.
|
||||
2. **Arrangement Pass**: After all widgets have had their sizes measured, all parents will arrange their children according to a layout algorithm specific to the given parent widget.
|
||||
3. **Render Pass**: All widgets are rendered to the screen.
|
||||
1. **Widget repaint**: Widgets with stale geometry are given a chance to rebuild their geometry.
|
||||
2. **Pre-submit pass**: Widget Effects are given a chance to run shaders on a widget's geometry.
|
||||
3. **Geometry submission**: Widget geometry is submitted to the GPU for rendering, and drawn to the screen.
|
||||
|
||||
The Layout Pass and Render Pass, being recursive, are extremely hot paths. For this reason, the UI system caches the results of the Layout Pass and Widget Repaint stages. They will be run once on a widget when it is first added to a parent, and again when the widget is invalidated.
|
||||
|
||||
## Invalidation
|
||||
|
||||
Widgets must invalidate their layout and geometry if a property on that widget changes, when that property affects the widget's layout or geometry. It is up to the widget's developer to decide whether a property affects a widget's layout/geometry, and up to the developer to invalidate the widget accordingly.
|
||||
|
||||
**You should only invalidate when you need to**, as invalidating a widget causes other widgets to invalidate. In some cases, invalidating a single widget can necessarily cause the entire widget tree to invalidate.
|
||||
|
||||
To invalidate a widget's layout, call `InvalidateLayout()` on the widget. Invalidating a widget's layout will invalidate the widget's `LayoutRoot` - causing a recursive invalidation of every widget within the LayoutRoot. In most cases, LayoutRoot is just the top-level widget. This is mostly unavoidable, as a layout invalidation implies a possible change in widget size and thus may require a parent to rearrange its children (and we can't predict whether that's actually true). You should only invalidate a widget's layout when a layout-related property changes.
|
||||
|
||||
If you need to invalidate the geometry of a widget, call `InvalidateGeometry()` on it. By default, this will only invalidate the one widget's geometry and cause it to repaint. If you need to invalidate a widget's geometry and that of all of its children recursively, call `InvalidateGeometry(true)`. You will rarely need to do this. You should only invalidate a widget's geometry when a visual property of the widget, such as a font/color, changes. Note that, when invalidating a widget's layout, the UI system will also recursively invalidating its geometry.
|
||||
|
||||
## Avoiding unnecessary layout updates
|
||||
|
||||
### Create layout islands
|
||||
|
||||
Separate complex UIs into their own layout islands when possible. When a giant layout invalidation occurs, it will only affect the widgets in the layout island.
|
||||
|
||||
In Socially Distant, modal overlays (such as System Settings and message boxes) are added as new toplevels to the UI system. This means that, when you navigate through System Settings, the game isn't needlessly refreshing the layout of the blurred desktop in the background.
|
||||
|
||||
On the desktop, Socially Distant marks the Info Panel as a layout root. This prevents Info Panel widgets from needlessly invalidating other desktop elements like the dock, or woese, open program windows.
|
||||
|
||||
To create a layout island, either:
|
||||
|
||||
1. add your root widget as a toplevel
|
||||
|
||||
```csharp
|
||||
// Using another widget:
|
||||
widget.GuiManager.TopLevels.Add(myLayoutIsland);
|
||||
```
|
||||
|
||||
2. create a custom widget that marks itself as its layout root:
|
||||
|
||||
```csharp
|
||||
public sealed class MyWidget : Widget
|
||||
{
|
||||
public MyWidget()
|
||||
{
|
||||
LayoutRoot = this;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> :warning: **Warning**
|
||||
>
|
||||
> Messing with `LayoutRoot` can cause layout bugs if your custom widget changes its size based onits children. You should only mark a widget as its own LayoutRoot if you know its size will be static. If you do get layout bugs, you need to invalidate the LayoutRoot's parent and that's what we're trying to actively avoid.
|
||||
|
||||
### Collapsing widgets
|
||||
|
||||
Setting a widget's Visibility to Collapsed suppresses future layout invalidations of that widget and its children. This is because the UI system knows that all collapsed widgets can't possibly have a non-zero size, and so doesn't bother to do a full layout pass on them at all.
|
||||
|
||||
### Avoid animating layout properties excessively
|
||||
|
||||
Sliding, growing, and shrinking animations are nice. But as with many things, there's an art in subtlety. You cannot avoid the layout updates needed during an animation, so avoid heavy animations.
|
||||
|
||||
### Remove parents before removing children
|
||||
|
||||
When removing widgets from other widgets, remove parent widgets first. Removing a child from a parent always causes a full invalidation of layout and geometry, so getting the widget out of the layout hierarchy should be top priority. One call to `Widget.InvalidateLayout()` resulting in a full layout invalidation is better than several.
|
||||
|
||||
### Handle Custom Properties with care
|
||||
|
||||
**In most cases**, changing a Custom Property on a widget requires invalidating that widget's layout. This is because there's no way for the UI system to know whether a custom property actually affects widget layout, and assumes that all of them do. Since many of them do, this works out well when custom properties are used effectively.
|
||||
|
||||
If possible, try to batch your custom properties into a `CustomPropertyObject`. You can only do this if your code is the one reading the custom properties, but it means you have control over whether the properties invalidate a widget's layout. For an example of how to implement this, see the `FlexPanel` and `FlexPanelProperties` code.
|
||||
|
||||
### Use ListAdapter for large lists
|
||||
|
||||
Using `ListAdapter` for larger lists allows common UI optimizations to be applied to all items in the list without you needing to worry about it. This allows you to benefit from future optimizations without needing to implement them.
|
||||
|
||||
Learn how to [create a List Adapter](List-Adapters)
|
||||
|
||||
## Avoiding geometry invalidations
|
||||
|
||||
### Use `RenderOpacity` for fade animations
|
||||
|
||||
You can avoid a widget repaint by using `RenderOpacity` to implement fade animations. This is because `RenderOpacity` can be applied to cached widget geometry.
|
||||
|
||||
### Use `Visibility.Hidden`
|
||||
|
||||
If you'd like a widget to be visually hidden, but still want it to contribute to layout (like a `CompositeIconWidget` does), it's prferrable to set the widget to Hidden instead of 0% opacity. Hidden and Collapsed widgets never get submitted to the GPU during render pass, and therefore do not repaint even if their geometry is invalidated.
|
||||
|
||||
### Use Widget Effects to your advantage
|
||||
|
||||
Socially Distant has a default user avatar texture. Avatars can change their color depending on context. The default avatar uses a Widget Effect to implement the recoloring. This allows the recoloring to be done on the GPU with a fragment shader, as part of the geometry submit pass. This means changing the color of a default avatar in Socially Distant **doesn't** cause a geometry invalidation. If you can pull this off, you're golden.
|
||||
|
||||
### ...But also use them sparingly.
|
||||
|
||||
We all love ourselves a nice Gaussian blur effect for a translucent widget background. Our GPUs? Not so much. Remember that art of subtlety thing, only use complex Widget Effects when you need to. If possible, write them in such a way where they can be accessed as a singleton and have their GPU resources shared across multiple widgets. For an example of how to do this, see the `BackgroundBlurWidgetEffect` code.
|
329
docs/ui/overview.md
Normal file
329
docs/ui/overview.md
Normal file
|
@ -0,0 +1,329 @@
|
|||
# UI Overview
|
||||
|
||||
Socially Distant uses a custom widget-based UI system that prioritizes automatic layout over manual layout. The UI system is built specifically to meet the game's advanced needs. Nonetheless, it's fairly simple to work with. This page is an overview of the most common widget types, and how everything fits together in the game.
|
||||
|
||||
## Visual Styles
|
||||
|
||||
In Socially Distant, and in Ritchie's Toolbox in general, widgets do not have a defined look by default. It is up to the game to provide a global visual style for all widgets. Socially Distant provides `SociallyDistantVisualStyle` as the UI's global visual style.
|
||||
|
||||
The game must define a global visual style. However, the game can also define widget-specific visual styles. This is useful for creating a visual style for an in-game website that gives the website its own distinct look and feel from the rest of the in-game OS's programs. When setting a custom visual style on a widget, all children of that widget inherit the custom visual style.
|
||||
|
||||
> :asterisk: **Note**
|
||||
>
|
||||
> Because widgets don't have a defined look without a Visual Style to dictate it, widgets also don't have a default font for text rendering. It is up to the Visual Style to provide all fonts used in the UI, with the exception of custom fonts set on specific widgets.
|
||||
|
||||
The Visual Style system should never be worked around. If you're designing a custom widget, you should integrate it with the game's Visual Style as much as you can. This allows your custom widget to be reusable, themeable, and to reman consistent within the game's art style. Consider modifying `SociallyDistantVisualStyle` to do the rendering for your custom widget if you are contributing to the base game.
|
||||
|
||||
## Widgets
|
||||
|
||||
Socially Distant's user interface is made of widgets. Widgets are assembled like building blocks to make more complex widgets, which can be further assembled together into more complex widrgets. Most widgets in the game are just groups of smaller widgets arranged in a certain way to accomplish a reusable layout.
|
||||
|
||||
## Custom Properties
|
||||
|
||||
Every widget has a set of properties shared across all widgets. Each specific widget also has a set of its own specific properties. For example, all widgets have a `HorizontalAlignment` property while only `TextWidget` s have a `TextAlign` property. You can get far with this. However, there are cases where that's not enough. Sometimes, a parent widget needs to know something about each of its child widgets that isn't common across all widget types. Sometimes, the Visual Style may support drawing widgets in a certain way but there's no way for it to know whether you want it to do that. This is where **Custom Properties** come in. All widgets support them.
|
||||
|
||||
Custom Properties is a system that lets you assign custom data to a widget. This data can then be picked up by some other part of the game. For example, to make a widget draw with a red background:
|
||||
|
||||
```csharp
|
||||
var widget = new Box();
|
||||
widget.SetCustomProperty(WidgetBackgrounds.Common);
|
||||
widget.SetCustomProperty(CommonColor.Red);
|
||||
```
|
||||
|
||||
The above code tells SociallyDistantVisualStyle to use a common color for the widget's background, and that the color should be the game's red accent color.
|
||||
|
||||
There are many more uses of custom properties, as you discover more of the UI system.
|
||||
|
||||
## Input Events
|
||||
|
||||
Unlike Windows Forms and WPF, and most other UI systems, not all widgets are aware of input events such as mouse movement or key presses. Widgets only listen to the input events they need to, and only fire events that they want to. For example, an input field does not fire a `KeyChar` event - rather it tells you when the input field's value has been changed or submitted.
|
||||
|
||||
If you need a widget to respond to an input event, you must wrap it in a parent widget that listens for the given input event. This means either using an existing widget, like `Button`, that handles the inputs you need. Otherwise, you need to create a custom widget.
|
||||
|
||||
When creating a custom widget, you inform the UI system of the input events you care about by implementing them as interfaces. The following interfaces can be implemented by a `Widget` class to receive input events.
|
||||
|
||||
**Mouse**
|
||||
|
||||
* `IMouseEnterHandler`: Receive an event when the mouse cursor enters the widget's content area.
|
||||
* `IMouseLeaveHandler`: Receive an event when the mouse cursor leaves the widget's content area.
|
||||
* `IMouseMoveHandler`: Receive an event every time the mouse cursor moves within the widgets' content area.
|
||||
* `IMouseDownHandler`: Receive an event when a mouse button is pressed on the widget.
|
||||
* `IMouseUpHandler`: Receive an event when a mouse button is released on the widget.
|
||||
* `IMouseClickHandler`: Receive an event when a mouse button is pressed and then released on the widget.
|
||||
* `IMouseScrollHandler`: Receive an event when the mouse wheel is scrolled on the widget.
|
||||
|
||||
**Drag and Drop**
|
||||
|
||||
* `IDragStartHandler`: Receive an event when the widget starts being dragged by the mouse.
|
||||
* `IDragHandler`: Receive an event while the mouse continues to drag the widget.
|
||||
* `IDragEndHandler`: Receive an event when the mouse stops dragging the widget.
|
||||
|
||||
**Keyboard**
|
||||
|
||||
* `IKeyDownHandler`: Receive an event when a key is pressed or held, if the widget is in focus.
|
||||
* `IKeyUpHandler`: Receive an event when a key is released, if the widget is in focus.
|
||||
* `IKeyCharHandler`: Receive an event when text is typed, if the widget is in focus.
|
||||
|
||||
**Preview Keyboard**
|
||||
|
||||
* `IPreviewKeyDownHandler`: Receive an event when a key is pressed or held, even if the widget isn't in focus.
|
||||
* `IPreviewKeyUpHandler`: Receive an event when a key is released, even if the widget isn't in focus.
|
||||
* `IPreviewKeyCharHandler`: Receive an event when text is typed, even if the widget isn't in focus.
|
||||
|
||||
**Keyboard Focus**
|
||||
|
||||
* `IGainFocusHandler`: Receive an event when the widget, or one of its children, gains keyboard focus.
|
||||
* `ILoseFocusHandler`: Receive an event when the widget, or one of its children, loses keyboard focus.
|
||||
|
||||
**Special**
|
||||
|
||||
* `IUpdateHandler`: Receive an event every frame.
|
||||
|
||||
When a widget receives any of the above events, the event is bubbled to the first parent who listens to the same event type. This bubbling repeats until the UI system reaches a widget with no parent. A widget can prevent the event from propagating to its parent by calling `e.Handle()` on the event.
|
||||
|
||||
A widget can also request keyboard focus in response to an event by calling `e.RequestFocus()`. For example, the `InputField` widget requests focus when you click on it. Requesting focus also prevents the event from propagating to a parent.
|
||||
|
||||
## Types of widgets
|
||||
|
||||
### Base widgets
|
||||
|
||||
Base widgets are widgets you can't directly use as UI elements. Rather, they serve as building blocks for actual UI elements, providing their common base functionality. You can write your own, but there are three types of base widgets that serve most needs.
|
||||
|
||||
#### `Widget`
|
||||
|
||||
This is the base class of **all** widgets, including other base widgets. It provides the default layout and rendering behaviour for all widgets, and several properties that all widgets must have.
|
||||
|
||||
* `MinimumSize`: The minimum size, in pixels, that a widget must take within its container.
|
||||
* `MaximumSize`: The maximum size, in pixels, that a widget is allowed to take within its container.
|
||||
* `Parent?`: A reference to the widget's parent widget. Can be null.
|
||||
* `RenderEffect?`: A reference to an object implementing `IWidgetEffect` that can be used to apply visual effects to a widget's geometry. Can be null.
|
||||
* `VisualStyle?` : A reference to an object implementing `IVisualStyle` that can be set to override the game's global visual style. Can be null and should be rarely used.
|
||||
* `HorizontalAlignment`: Defines the widget's horizontal alignment (left, center, right, or stretch) within the space given to it by its parent widget. Default is `Stretch`.
|
||||
* `VerticalAlignment`: Defines the widget's vertical alignment (top, middle, right, stretch) within the space given to it by its parent widget. Default is `Stretch`.
|
||||
* `Padding`: An amount, in pixels, for each cardinal direction, of space between a given edge of the widget and the corresponding edge of the space given to the widget by its parent.
|
||||
* `Margin`: An amount, in pixels, for each cardinal direction, of space between a given edge of the widget and the corresponding edge of the widget's content/children.
|
||||
* `RenderOpacity`: The visual translucency, between `0` and `1`, of the widget and its children. Default is `1`.
|
||||
* `ContentArea`: The calculated position and size of the widget, expressed as a layout rectangle, as of the last layout update.
|
||||
* `Enabled` : A value indicating whether the widget is enabled or disabled. If disabled, the widget appears visually grayed out and doesn't receive any input events. Default is enabled.
|
||||
* `Visibility`: A value defining the widget's visibility (visible, hiddem, or collapsed). If visible, the widget contributes to layout and renders. If hidden, the widget contributes to layout but doesn't render. If collapsed, the widget doesn't render or contribute to layout. Default is visible.
|
||||
|
||||
#### `ContentWidget`
|
||||
|
||||
A `ContentWidget` is just a `Widget` that can contain a single child. The most widely-used examples are `Box`, `Button` , and `InfoBox`. Although users of a `ContentWidget` can only assign one child to the widget, the widget itself may add multiple children. This is how `InfoBox` is able to have a decorative colored strip as well as a title.
|
||||
|
||||
The only special property of `ContentWidget` is `Content`, which is a reference to a widget. Setting `Content` to another widget will assign that widget as the child of the `ContentWidget`.
|
||||
|
||||
#### `ContainerWidget`
|
||||
|
||||
A `ContainerWidget` is a `Widget` that can contain multiple child widgets. The most common examples are `FlexPanel`, `StackPanel`, `ScrollView`, `WrapPanel` and `OverlayPanel`. Like a `ContentWidget`, a `ContainerWidget` may contain more children that cannot be directly accessed (such as the "new tab" button on window tab lists).
|
||||
|
||||
The only special property of a `ContainerWidget` is its `ChildWidgets` collection. Use this to add, remove, and otherwise access the widget's children.
|
||||
|
||||
### UI Elements
|
||||
|
||||
These widgets are all building blocks for more complex user interfaces in the game.
|
||||
|
||||
#### TextWidget
|
||||
|
||||
`TextWidget` is an extremely-common, and **extremely versatile** text renderer for the entire UI system. It ain't your grandma's text label, as it supports rich text markup and even images.
|
||||
|
||||
The most important properties of a `TextWidget` are:
|
||||
|
||||
* `Text`: The text displayed in the widget.
|
||||
* `TextAlign`: The horizontal alignment of the text (left, center, right) within the widget's content area. Defaults to left.
|
||||
* `WordWrapped`: Whether the text is word-wrapped within the widget's content area. Defaults to `false` for performance reasons.
|
||||
* `UseMarkup`: Whether the widget uses rich text markup. Defaults to `false` for performance reasons.
|
||||
* `ShowMarkup`: When `UseMarkup` is turned on, determines whether the raw markup is displayed. Defaults to `false`, and should generally only be used when writing input fields.
|
||||
* `Color?`: The color (or, when in markup mode, **default** color) of text. Can be null. If set to null, the color comes from the widget's visual style. Defaults to null.
|
||||
* `FontSize?`: The font size (or, when in markup mode, **default** font size), in pixels, of the text. Can be null. If null, the value comes from the visual style. Defaulrs to null.
|
||||
* `FontWeight?`: Determines the weight (boldness) of the text. If in markup mode, the `<b>` tag can override this to `FontWeight.Bold`. Defaults to `FontWeight.Normal`.
|
||||
* `Font?`: The font style of the text. Can be null. If null, the font style comes from the visual style. Defaults to null.
|
||||
|
||||
#### InputField
|
||||
|
||||
This is a customizable text entry field using `TextWidget` for its display. You can control most of the same properties you can with `TextWidget` itself. You can also control how the Enter Key and mouse behave in the input field. These are the most important properties of the input field:
|
||||
|
||||
* `Value`: The current value of the input field.
|
||||
* `Placeholder`: Text to display in the input field when it's empty.
|
||||
* `Multiline`: Whether the text can occupy multiple lines. Defaults to false.
|
||||
* `WordWrapped`: Whether the input field wraps its display horizontally or scrolls it. Defaults to false
|
||||
|
||||
#### Toggle
|
||||
|
||||
Toggles are basic on/off switches. They can be displayed as checkboxes or left/right toggle switches (this is purely cosmetic, they function identically).
|
||||
|
||||
* `UseSwitchVariant`: Whether the toggle is a checkbox or a switch.
|
||||
* `ToggleValue`: The current toggle value, on or off.
|
||||
|
||||
#### Button
|
||||
|
||||
A clickable surface. The button doesn't visually alter itself or its children in any way, unless the visual style decides to render a button background (Socially Distant does not). The Button is a `ContentWidget`, and is used to make any other widget clickable. This is useful for creating custom UI elements where you need a clickable area (such as the close button on a tab), but want to deal with visual interactions yourself.
|
||||
|
||||
#### StringDropdown
|
||||
|
||||
A simple selection dropdown that presents a list of strings to the user and allows them to select one. The code can be used as a reference implementation for other `Dropdown`-based widgets, if you need a more-advanced item display than just text. However, in most cases, `StringDropdown` is all you need.
|
||||
|
||||
#### Icon
|
||||
|
||||
A widget that can be used to display a Unicode text icon at any size, using the visual style's icon font. In Socially Distant, this is used to display Material Design icons.
|
||||
|
||||
#### Image
|
||||
|
||||
A widget used to display a picture or other texture.
|
||||
|
||||
#### Slider
|
||||
|
||||
A widget used to allow the user to input a ranged number value. The slider can be vertical or horizontal, and the mouse is used to drag the slider between its minimum and maximum values.
|
||||
|
||||
### Layout Widgets
|
||||
|
||||
Layout Widgets are `ContainerWidget` widgets that apply a specific layout algorithm to their children. When putting complex UIs together, layout widgets are the most useful tool you have.
|
||||
|
||||
#### StackPanel
|
||||
|
||||
StackPanel arranges its children in a stack, either vertically or horizontally. You can define the direction (vertical by default), and the spacing<span dir=""> </span>between each child (in pixels, 0 by default). StackPanel is the most common layout widget used in the game.
|
||||
|
||||
#### WrapPanel
|
||||
|
||||
WrapPanel behaves similarly to StackPanel. The main difference is its children wrap to a new line when they can no longer fit on the current line. For this reason, you can set both the horizontal and vertical spacing between children.
|
||||
|
||||
If the wrap panel's direction is vertical, children are arranged top-to-bottom and wrap at the bottom edge. When in horizontal mode, children are arranged left-to-right and wrap at the right edge.
|
||||
|
||||
Wrap Panels are used for window tab lists and file grids.
|
||||
|
||||
#### FlexPanel
|
||||
|
||||
FlexPanel is a basic implementation of the flexbox algorithm. Children can either be auto-sized or sized proportionally within the flex panel. Other than that, it has the same properties as `StackPanel`.
|
||||
|
||||
To change the sizing behaviour of a FlexPanel's child, you use the `FlexPanelProperties` custom property. For example, to make a child fill 100% of the flex panel's remaining space:
|
||||
|
||||
```csharp
|
||||
var flex = new FlexPanel();
|
||||
var widget = new Box();
|
||||
flex.ChildWidgets.Add(widget);
|
||||
|
||||
var flexSettings = widget.GetCustomProperties<FlexPanelProperties>();
|
||||
flexSettings.Mode = SizeMode.Proportional;
|
||||
flexSettings.Percentage = 1f;
|
||||
```
|
||||
|
||||
FlexPanels are used by the game's Status Bar, Dock, window title areas, and to arrange almost all of the game's top-level layout.
|
||||
|
||||
#### ScrollView
|
||||
|
||||
ScrollView behaves identically to a vertical StackPanel, but scrolls its children vertically. It can also render a scrollbar if its children don't fit within the ScrollView's content area. The ScrollView shrinks to fit its children, and expands until its children can't fit within the ScrollView's parent.
|
||||
|
||||
> :warning: **Warning**
|
||||
>
|
||||
> Adding a ScrollView as a child of a FlexPanel can cause some **NASTY** layout bugs if you don't do it correctly. You must either set the ScrollView's flex mode to proportional, or make sure there's a mimimum width and maximum height set on the Scrollview or one of its parents.
|
||||
|
||||
#### OverlayPanel
|
||||
|
||||
OverlayPanel just allows you to overlay multiple children on top of each other using the default layout algorithm of all `Widget`s. This is used by the game's modal dialogs, as well as by the cards in Info Panel to display a close button in their top-right corner.
|
||||
|
||||
### Shell Widgets
|
||||
|
||||
Shell Widgets are custom widgets provided by Socially Distant (and not Ritchie's Toolbox) that implement game-specific needs.
|
||||
|
||||
#### SimpleTerminal
|
||||
|
||||
A port of the suckless simple terminal emulator (`st`). It's used by the game's Terminal, and is by no means simple to use.
|
||||
|
||||
#### TextButton
|
||||
|
||||
A simple clickable button with centered text on it.
|
||||
|
||||
#### CompositeIconWidget
|
||||
|
||||
Used to draw a `CompositeIcon` value, which can be either a texture or a Material icon. This is used for window icons, Dock icons, and file icons.
|
||||
|
||||
#### ToolbarIcon
|
||||
|
||||
A clickable `CompositeIconWidget`. Used for the navigation buttons in File Manager and Web Browser's toolbar.
|
||||
|
||||
#### InfoBox
|
||||
|
||||
A box with a colored decorative strip, optional title, and content. Used by modal question/info dialogs, email messages, and Markdown blockquotes.
|
||||
|
||||
#### ListItem
|
||||
|
||||
A clickable and selectable widget. Used for various item lists in the game, like the player's email inbox, DM list, and the Category list in System Settings.
|
||||
|
||||
#### ListItemWithHeader
|
||||
|
||||
A `ListItem` with a dark gray text label above it. Used in various sectioned lists like the Categories list in System Settings.
|
||||
|
||||
## Writing a custom Widget
|
||||
|
||||
If you find yourself creating the same set of widgets over and over again, and laying them out the same way, then it's time for you to create a custom widget. So here's how. Let's create a simple widget that displays an icon and a text label next to it, like a FileGrid does.
|
||||
|
||||
First, we define a custom class inheriting `Widget`.
|
||||
|
||||
```csharp
|
||||
public class FileIcon : Widget
|
||||
{
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
Next, declare and instantiate all of the child widgets you'll need to create this file icon layout. We'll need an icon, a label, and a way to stack them next to each other.
|
||||
|
||||
```csharp
|
||||
public class MyWidget : Widget
|
||||
{
|
||||
private readonly StackPanel root = new();
|
||||
private readonly CompositeIconWidget icon = new();
|
||||
private readonly TextWidget label = new();
|
||||
}
|
||||
```
|
||||
|
||||
We must add the root of our custom widget as a child to the custom widget itself. If we were just instantiating `MyWidget`, we wouldn't be able to add children to it because `Widget.Children` is `protected` to prevent you from adding children to widgets where that doesn't make sense. So, we must add our custom widget's children within the `MyWidget` constructor.
|
||||
|
||||
```csharp
|
||||
public MyWidget()
|
||||
{
|
||||
Children.Add(root);
|
||||
}
|
||||
```
|
||||
|
||||
> :asterisk: **Code review note**
|
||||
>
|
||||
> It is a good practice to name the root widget of your custom widget `root`, and to make sure it is the first widget instantiated and added. This makes it clear that you are building a custom widget that just uses other widgets as building blocks. This also means you can change the type of layout widget used later.
|
||||
|
||||
Next, add the icon and label as child widgets to the `root` widget.
|
||||
|
||||
```csharp
|
||||
root.ChildWidgets.Add(icon);
|
||||
root.ChildWidgets.Add(label);
|
||||
```
|
||||
|
||||
You can now instantiate and use `MyWidget` inside other widgets! It's up to you to expose all of the properties, events, and public methods you need to control the custom widget. You can also set any visual and layout properties you need to during the `MyWidget` constructor, for example:
|
||||
|
||||
```csharp
|
||||
public MyWidget()
|
||||
{
|
||||
root.Direction = Direction.Horizontal;
|
||||
root.Spacing = 3;
|
||||
root.Padding = 3;
|
||||
|
||||
label.VerticalAlignment = VerticalAlignment.Middle;
|
||||
icon.VerticalAlignment = VerticalAlignment.Middle;
|
||||
|
||||
icon.IconSize = 16;
|
||||
|
||||
label.WordWrapped = true;
|
||||
|
||||
Children.Add(root);
|
||||
root.ChildWidgets.Add(icon);
|
||||
root.ChildWidgets.Add(label);
|
||||
}
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
This should cover the basics of using the UI system, and should give plenty of common widgets to work with. However, you might want to learn more things you can do.
|
||||
|
||||
* Learn how to add [programs](programs) and [websites](websites) to Socially Distant
|
||||
* Learn how to [optimize UI for performance](ui-performance-optimizations)
|
||||
* Learn how to [create list views with ListAdapter](List-Adapters)
|
||||
* Learn how to [create cystom dropdowns](custom-dropdowns)
|
Loading…
Reference in a new issue