Created
July 24, 2021 22:31
-
-
Save hkparker/3b8c44685119d1704e33cb5d3f2cba6e to your computer and use it in GitHub Desktop.
Fyne chat bubble that mostly works
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"image/color" | |
"strings" | |
"time" | |
"fyne.io/fyne/v2" | |
"fyne.io/fyne/v2/app" | |
"fyne.io/fyne/v2/canvas" | |
"fyne.io/fyne/v2/container" | |
"fyne.io/fyne/v2/theme" | |
"fyne.io/fyne/v2/widget" | |
log "github.com/sirupsen/logrus" | |
) | |
func main() { | |
demo := app.New() | |
window := demo.NewWindow("Demo") | |
window.SetMaster() | |
userIcon, err := fyne.LoadResourceFromURLString("https://www.searchpng.com/wp-content/uploads/2019/02/Profile-PNG-Icon.png") | |
if err != nil { | |
log.Fatal(err.Error()) | |
} | |
userProfileButton := widget.NewButtonWithIcon("", userIcon, func() { | |
log.Info("user wants to open the profile") | |
}) | |
content := container.NewVScroll( | |
container.NewVBox( | |
newChatBubble("Alice", "Hello, world!", false, time.Now().Unix(), userProfileButton), | |
newChatBubble("Alice", "I can also not render a profile image", false, time.Now().Unix(), nil), | |
newChatBubble("Bob", "Hello!", true, time.Now().Unix(), nil), | |
newChatBubble("Bob", "Ok.\n\nThis message has multiple lines.\n\nWord wrapping works based on the longest line in the message.", true, time.Now().Unix(), nil), | |
), | |
) | |
window.SetContent(content) | |
window.Show() | |
demo.Run() | |
} | |
type chatBubble struct { | |
widget.BaseWidget | |
id string | |
timestamp int64 | |
username string // TODO: needs to update when user changes name. user.name should be a binding.String and should add a listener | |
message *widget.Label | |
icon *widget.Button | |
outgoing bool | |
} | |
func newChatBubble(username, message string, outgoing bool, timestamp int64, icon *widget.Button) *chatBubble { | |
// TODO: I'm just using a widget.Label because it comes with wrapping out of the box, and all the wrapping code | |
// isn't exported and non-trivial to copy out. A canvas.Text would probably be better as I would have more | |
// control over the presentation of the font and could hopefully enable click and drag selection for copying | |
// text, but how to do this remains an open question. Also, using a label comes with padding out of the box | |
// that I don't want. | |
messageLabel := widget.NewLabel(message) | |
messageLabel.Wrapping = fyne.TextWrapWord | |
// Make sure this button has an icon and does not have text | |
if icon != nil { | |
if icon.Text != "" { | |
log.Fatal("cannot create a chatBubble with a profile button containing text") | |
} | |
if icon.Icon == nil { | |
log.Fatal("chatBubble with a profile button must contain an icon") | |
} | |
} | |
bubble := &chatBubble{ | |
username: username, | |
message: messageLabel, | |
icon: icon, | |
outgoing: outgoing, | |
timestamp: timestamp, | |
} | |
bubble.ExtendBaseWidget(bubble) | |
return bubble | |
} | |
func (bubble *chatBubble) CreateRenderer() fyne.WidgetRenderer { | |
bubble.ExtendBaseWidget(bubble) | |
usernameText := canvas.NewText(bubble.username, theme.ForegroundColor()) // TODO: users should have unique colors derived from ID | |
usernameText.TextStyle.Bold = true | |
timestampText := canvas.NewText(time.Unix(bubble.timestamp, 0).Format("1/2 15:04"), theme.ForegroundColor()) | |
timestampText.TextSize = theme.TextSize() * 0.6 | |
// TODO: round the corners when possible: https://github.com/fyne-io/fyne/issues/1090 | |
// Incoming messages have a grey background and justify to the left | |
background := &canvas.Rectangle{FillColor: color.NRGBA{0x20, 0x20, 0x20, 0xff}} | |
if bubble.outgoing { | |
// Sent messages have a blue background and justify to the right | |
background = &canvas.Rectangle{FillColor: color.NRGBA{0, 0x23, 0x75, 0xff}} | |
} | |
renderer := &bubbleRenderer{ | |
background: background, | |
bubble: bubble, | |
username: usernameText, | |
message: bubble.message, | |
icon: bubble.icon, | |
timestamp: timestampText, | |
longestLine: longestLine(bubble.message.Text), // We have to know the longest line for width calculation, more on that later | |
objects: []fyne.CanvasObject{ | |
background, | |
usernameText, | |
bubble.message, | |
timestampText, | |
}, | |
verticalPaddingAboveBackground: theme.Padding(), | |
verticalPaddingAboveUsername: theme.Padding() * 2, | |
verticalPaddingAboveMessage: theme.Padding() * 2, | |
verticalPaddingAboveTimestamp: theme.Padding() * 2, | |
verticalPaddingAboveBackgroundEnd: theme.Padding() * 2, | |
verticalPaddingAboveEnd: theme.Padding(), | |
horizontalPaddingSideOfBackground: theme.Padding() * 2, | |
horizontalPaddingSideOfText: theme.Padding() * 2, | |
horizontalPaddingSideOfIcon: theme.Padding() * 2, | |
horizontalPaddingMinimumOnOtherSize: theme.Padding() * 7, | |
} | |
if bubble.icon != nil { | |
renderer.objects = append(renderer.objects, bubble.icon) | |
} | |
return renderer | |
} | |
type bubbleRenderer struct { | |
username *canvas.Text | |
message *widget.Label | |
timestamp *canvas.Text | |
icon *widget.Button | |
longestLine string | |
background *canvas.Rectangle | |
bubble *chatBubble | |
objects []fyne.CanvasObject | |
// Easier to read the Layout math by using these variable names | |
verticalPaddingAboveBackground float32 | |
verticalPaddingAboveUsername float32 | |
verticalPaddingAboveMessage float32 | |
verticalPaddingAboveTimestamp float32 | |
verticalPaddingAboveBackgroundEnd float32 | |
verticalPaddingAboveEnd float32 | |
horizontalPaddingSideOfBackground float32 | |
horizontalPaddingSideOfText float32 | |
horizontalPaddingSideOfIcon float32 | |
horizontalPaddingMinimumOnOtherSize float32 | |
} | |
func (renderer *bubbleRenderer) Layout(size fyne.Size) { | |
usernameSize := renderer.username.MinSize() | |
timestampSize := renderer.timestamp.MinSize() | |
allVerticalPadding := renderer.verticalPaddingAboveBackground + | |
renderer.verticalPaddingAboveUsername + | |
renderer.verticalPaddingAboveMessage + | |
renderer.verticalPaddingAboveTimestamp + | |
renderer.verticalPaddingAboveBackgroundEnd + | |
renderer.verticalPaddingAboveEnd | |
// We multiply the things that are on both sides by 2 | |
allHorizontalPadding := renderer.horizontalPaddingSideOfBackground*2 + | |
renderer.horizontalPaddingSideOfText*2 + | |
renderer.horizontalPaddingMinimumOnOtherSize | |
// Resize the message label into how big of space we're actually going to give it. That's the total | |
// size that we're being asked to layout in, minus the space in our widget used for padding and other | |
// items like the username and timestamp text | |
renderer.message.Resize( | |
size.Subtract(fyne.Size{ | |
Width: allHorizontalPadding, | |
Height: allVerticalPadding + usernameSize.Height + timestampSize.Height, | |
}), | |
) | |
messageSize := renderer.message.MinSize() | |
// | |
// Now we take some measurments for how much of the available space we want to use for the bubble vs how much | |
// we need to reserve for space on the non-justified side, and calculate offsets we're going to use later | |
// | |
// Our xOffset is far how to scoot everything to the right when we're right justified, or how much we're going to scoot things right to fit an icon when we're left justified | |
xOffset := renderer.horizontalPaddingMinimumOnOtherSize | |
// Creating this variable just so things below are a little more readable | |
availableWidth := size.Width | |
// We need to figure out which text in the bubble is going to be the widest so we can size the colored background accordingly | |
// We do this by measuring all the text we're going to print in the bubble, and comparing them and their padding | |
takenTimestampWidth := fyne.MeasureText(renderer.timestamp.Text, renderer.timestamp.TextSize, renderer.timestamp.TextStyle).Width + renderer.horizontalPaddingSideOfText*2 | |
takenUsernameWidth := fyne.MeasureText(renderer.username.Text, renderer.username.TextSize, renderer.username.TextStyle).Width + renderer.horizontalPaddingSideOfText*2 | |
// For multi-line messages, we measure the longest line. Note: this measurment doesn't account for line wrapping, so we | |
// will have to detect when it's going to wrap and make some additional adjustments based on that. | |
takenMessageWidth := fyne.MeasureText(renderer.longestLine, theme.TextSize(), renderer.message.TextStyle).Width + renderer.horizontalPaddingSideOfText*2 // Can't get the TextSize of a label, use theme.TextSize() | |
// We start by assuming the timestamp is the widest text in the bubble, then replace that definition if anything else is wider. | |
// It is importatnt to remember in that this variable includes padding built in | |
maximumTextWidth := takenTimestampWidth | |
if takenUsernameWidth > maximumTextWidth { | |
maximumTextWidth = takenUsernameWidth | |
} | |
if takenMessageWidth > maximumTextWidth { | |
maximumTextWidth = takenMessageWidth | |
} | |
// | |
// Now, if we're not going to wrap the text then we can expand the xOffset to fill in the remaining space. | |
// We determine this by checking if the maximum text width is going to be larger than what we have available. | |
// If not, we increase the xOffset to make up the difference | |
// | |
if maximumTextWidth+allHorizontalPadding < availableWidth { | |
// We're not going to wrap, we can use up all the space that isn't the text with padding | |
xOffset = availableWidth - maximumTextWidth | |
} | |
// Lastly, there's going to be padding on the justified side of the bubble. Reduce the offset for that. | |
xOffset -= renderer.horizontalPaddingSideOfBackground * 2 | |
// | |
// For incoming messages that contain an icon, we need to scoot the whole bubble out a bit so that we can place an icon on the side | |
// | |
if !renderer.bubble.outgoing { | |
// Outgoing messages are right justified, so we undo the offset calculation, but we scoot out a bit if we need to fit an icon in | |
xOffset = 0 | |
if renderer.icon != nil { | |
xOffset += theme.IconInlineSize() + renderer.horizontalPaddingSideOfIcon | |
} | |
} | |
// | |
// Now, make the correct size bubble and place all the objects where they belong, adding the xOffset where needed for right justification | |
// | |
// Determine the size of the chat bubble | |
backgroundHeight := renderer.verticalPaddingAboveUsername + | |
usernameSize.Height + | |
renderer.verticalPaddingAboveMessage + | |
messageSize.Height + | |
renderer.verticalPaddingAboveTimestamp + | |
timestampSize.Height + | |
renderer.verticalPaddingAboveBackgroundEnd | |
renderer.background.Resize(fyne.Size{ | |
Height: backgroundHeight, | |
Width: maximumTextWidth + renderer.horizontalPaddingSideOfBackground*2, | |
}) | |
// Put the username on the top of the bubble | |
usernameX := renderer.horizontalPaddingSideOfBackground + renderer.horizontalPaddingSideOfText | |
renderer.username.Move(fyne.Position{ | |
X: xOffset + usernameX, | |
Y: renderer.verticalPaddingAboveBackground + renderer.verticalPaddingAboveUsername, | |
}) | |
// Put the message underneith the username | |
messageX := xOffset + | |
renderer.horizontalPaddingSideOfBackground + | |
renderer.horizontalPaddingSideOfText - | |
theme.Padding() // subtract theme.Padding() to compensate for widget.Label's built-in padding, TODO: remove if/when using canvas.Text | |
messageY := renderer.verticalPaddingAboveBackground + | |
renderer.verticalPaddingAboveUsername + | |
usernameSize.Height + | |
renderer.verticalPaddingAboveMessage | |
renderer.message.Move(fyne.Position{ | |
X: messageX, | |
Y: messageY, | |
}) | |
// Put the timestamp at the bottom left or bottom right depending on justification, half way less indented than the message | |
timestampX := float32(0) | |
if renderer.bubble.outgoing { | |
timestampX = availableWidth - renderer.horizontalPaddingSideOfBackground - renderer.horizontalPaddingSideOfText/2 - timestampSize.Width | |
} else { | |
timestampX = xOffset + renderer.horizontalPaddingSideOfBackground + renderer.horizontalPaddingSideOfText/2 | |
} | |
timestampY := renderer.verticalPaddingAboveBackground + | |
renderer.verticalPaddingAboveUsername + | |
usernameSize.Height + | |
renderer.verticalPaddingAboveMessage + | |
messageSize.Height + | |
renderer.verticalPaddingAboveTimestamp | |
renderer.timestamp.Move(fyne.Position{ | |
X: timestampX, | |
Y: timestampY, | |
}) | |
// If an icon was supplied, place it at the bottom left of the message bubble | |
if renderer.icon != nil { | |
iconY := renderer.verticalPaddingAboveUsername + | |
usernameSize.Height + | |
renderer.verticalPaddingAboveMessage + | |
messageSize.Height + | |
renderer.verticalPaddingAboveTimestamp + | |
timestampSize.Height + | |
renderer.verticalPaddingAboveBackgroundEnd - | |
theme.IconInlineSize()/2 | |
renderer.icon.Move(fyne.Position{ | |
X: theme.IconInlineSize()/2 + renderer.horizontalPaddingSideOfIcon, // For some reason renderer.icon.Size() is 0 so I can't use that, but I know buttons use theme.IconInlineSize() | |
Y: iconY, | |
}) | |
} | |
// Place the background | |
bubbleX := renderer.horizontalPaddingSideOfBackground | |
renderer.background.Move(fyne.Position{ | |
X: xOffset + bubbleX, | |
Y: renderer.verticalPaddingAboveBackground, | |
}) | |
} | |
func (renderer *bubbleRenderer) MinSize() (size fyne.Size) { | |
usernameSize := renderer.username.MinSize() | |
messageSize := renderer.message.MinSize() | |
timestampSize := renderer.timestamp.MinSize() | |
allVerticalPadding := renderer.verticalPaddingAboveBackground + | |
renderer.verticalPaddingAboveUsername + | |
renderer.verticalPaddingAboveMessage + | |
renderer.verticalPaddingAboveTimestamp + | |
renderer.verticalPaddingAboveBackgroundEnd + | |
renderer.verticalPaddingAboveEnd | |
return fyne.Size{ | |
Width: renderer.horizontalPaddingSideOfBackground*2 + renderer.horizontalPaddingSideOfText*2 + messageSize.Width, | |
Height: allVerticalPadding + usernameSize.Height + messageSize.Height + timestampSize.Height, | |
} | |
} | |
func (renderer *bubbleRenderer) Refresh() { | |
renderer.username.Text = renderer.bubble.username | |
renderer.message = renderer.bubble.message | |
renderer.longestLine = longestLine(renderer.message.Text) | |
//r.updateIconAndText() | |
//r.applyTheme() // TODO: got these from the button example, do I need to build equivalents? | |
//r.background.Refresh() | |
renderer.Layout(renderer.bubble.Size()) | |
//canvas.Refresh(renderer.bubble.super()) | |
} | |
func (renderer *bubbleRenderer) Destroy() { | |
// TODO: do I need to do something here? | |
} | |
func (renderer *bubbleRenderer) Objects() []fyne.CanvasObject { | |
return renderer.objects | |
} | |
func (renderer *bubbleRenderer) padding() float32 { | |
return theme.Padding() * 2 | |
} | |
func longestLine(message string) string { | |
lines := strings.Split(message, "\n") // TODO: is '\n' too OS specific? Maybe do this https://stackoverflow.com/a/49963413 | |
longest := "" | |
for _, line := range lines { | |
if len(line) > len(longest) { | |
longest = line | |
} | |
} | |
return longest | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment