Room screen: timeline message bubbles (#91)

* #34 Create `TimelineItemStylerView`

* #34 Add styler view into different type of cells

* #34 Use placeholder avatar image on room list

* #34 Add `isOutgoing` param to event based timeline item

* #34 Compute `isOutgoing` for timeline items

* #34 Update sender info view in timeline

* #34 Update mock timeline items

* #34 Rename `EventBasedTimelineView` to `EventBasedTimelineSenderView`

* #34 Change padding on timeline cells

* #34 Create `TimelineItemStylerView` to move content into a bubble if needed

* #34 Use styler view in all of the timeline item views

* #34 Make timestamp more readable on images

* #34 Little layout tweaks

* #34 Add changelog

* #34 Fix code smells

* #34 Set text colors on timeline items

* #34 Fix background color of the timeline

* #34 Fix PR remarks

* #34 Set background colors explicitly on remaining screens

* #34 Reduce min bubble width and make it a scaled metric

* #34 Refactor `PlaceholderAvatarImage` to accept a text only

* #34 Fix code smell

* #34 Fix further comments
This commit is contained in:
ismailgulek
2022-06-23 14:54:29 +03:00
committed by GitHub
parent 5df1411a7e
commit b6b8b4be26
28 changed files with 451 additions and 162 deletions
@@ -14,13 +14,17 @@ struct EmoteRoomTimelineView: View {
var body: some View {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
HStack(alignment: .top) {
Image(systemName: "face.dashed").padding(.top, 1.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
HStack(alignment: .top) {
Image(systemName: "face.dashed").padding(.top, 1.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
.foregroundColor(.element.primaryContent)
}
}
}
}
@@ -52,6 +56,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: senderId)
}
}
@@ -0,0 +1,93 @@
//
// EventBasedTimelineSenderView.swift
// ElementX
//
// Created by Stefan Ceriu on 18/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct EventBasedTimelineSenderView: View {
let timelineItem: EventBasedTimelineItemProtocol
@ScaledMetric private var avatarSize = 26
var body: some View {
if timelineItem.shouldShowSenderDetails {
VStack {
Spacer()
.frame(height: 8)
HStack(alignment: .top, spacing: 4) {
avatar
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
.font(.body)
.foregroundColor(.element.primaryContent)
.fontWeight(.semibold)
.lineLimit(1)
}
}
}
}
@ViewBuilder private var avatar: some View {
ZStack(alignment: .center) {
if let avatar = timelineItem.senderAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.overlay(Circle().stroke(Color.element.accent))
} else {
PlaceholderAvatarImage(text: timelineItem.senderDisplayName ?? timelineItem.senderId)
}
}
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.overlay(
Circle()
.stroke(Color.element.background, lineWidth: 2)
)
.animation(.default, value: timelineItem.senderAvatar)
}
}
struct EventBasedTimelineSenderView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
EventBasedTimelineSenderView(timelineItem: item1)
EventBasedTimelineSenderView(timelineItem: item2)
}
.frame(maxHeight: 160)
.previewLayout(.sizeThatFits)
}
private static var item1: EventBasedTimelineItemProtocol {
TextRoomTimelineItem(id: UUID().uuidString,
text: "Some text",
timestamp: "",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "",
senderDisplayName: "Bob")
}
private static var item2: EventBasedTimelineItemProtocol {
TextRoomTimelineItem(id: UUID().uuidString,
text: "Some text",
timestamp: "",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "",
senderDisplayName: "Some long display name for a user")
}
}
@@ -1,79 +0,0 @@
//
// EventBasedTimelineView.swift
// ElementX
//
// Created by Stefan Ceriu on 18/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct EventBasedTimelineView: View {
let timelineItem: EventBasedTimelineItemProtocol
var body: some View {
if timelineItem.shouldShowSenderDetails {
HStack {
avatar
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
.font(.footnote)
.bold()
Text(timelineItem.timestamp)
.font(.footnote)
}
}
}
@ViewBuilder private var avatar: some View {
ZStack(alignment: .center) {
if let avatar = timelineItem.senderAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.overlay(Circle().stroke(Color.element.accent))
} else {
PlaceholderAvatarImage(firstCharacter: String(firstLetter))
}
}
.clipShape(Circle())
.frame(width: 24.0, height: 24.0)
.animation(.default, value: timelineItem.senderAvatar)
}
private var firstLetter: String {
if let senderDisplayName = timelineItem.senderDisplayName {
return senderDisplayName.prefix(1).uppercased()
} else {
return timelineItem.senderId.prefix(2).suffix(1).uppercased()
}
}
}
struct EventBasedTimelineView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
EventBasedTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
senderId: "Bob"))
EventBasedTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
senderId: "Anne"))
}
}
private static func itemWith(text: String, timestamp: String, senderId: String) -> TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
senderId: senderId)
}
}
@@ -22,10 +22,12 @@ struct FormattedBodyText: View {
.frame(width: 4.0)
Text(component.attributedString)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
}
} else {
Text(component.attributedString)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.element.primaryContent)
}
}
}
@@ -15,36 +15,40 @@ struct ImageRoomTimelineView: View {
var body: some View {
if timelineItem.image != nil || timelineItem.blurhash != nil { // Fixes view heights after loading finishes
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
Text(timelineItem.text)
if let image = timelineItem.image {
if let aspectRatio = timelineItem.aspectRatio {
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
if let image = timelineItem.image {
if let aspectRatio = timelineItem.aspectRatio {
Image(uiImage: image)
.resizable()
.aspectRatio(aspectRatio, contentMode: .fit)
} else {
Image(uiImage: image)
.resizable()
.scaledToFit()
}
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(aspectRatio, contentMode: .fit)
} else {
Image(uiImage: image)
.resizable()
.scaledToFit()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
.animation(.default, value: timelineItem.image)
.frame(maxHeight: 1000.0)
} else {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
Text(timelineItem.text)
HStack {
Spacer()
ProgressView("Loading")
Spacer()
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
HStack {
Spacer()
ProgressView("Loading")
Spacer()
}
}
}
}
@@ -64,6 +68,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Some image",
timestamp: "Now",
shouldShowSenderDetails: false,
isOutgoing: false,
senderId: "Bob",
source: nil,
image: UIImage(systemName: "photo")))
@@ -72,6 +77,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Some other image",
timestamp: "Now",
shouldShowSenderDetails: false,
isOutgoing: false,
senderId: "Bob",
source: nil,
image: nil))
@@ -80,6 +86,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
text: "Blurhashed image",
timestamp: "Now",
shouldShowSenderDetails: false,
isOutgoing: false,
senderId: "Bob",
source: nil,
image: nil,
@@ -14,13 +14,17 @@ struct NoticeRoomTimelineView: View {
var body: some View {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
HStack(alignment: .top) {
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
HStack(alignment: .top) {
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
.foregroundColor(.element.primaryContent)
}
}
}
}
@@ -50,9 +54,10 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
private static func itemWith(text: String, timestamp: String, senderId: String) -> NoticeRoomTimelineItem {
return NoticeRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
senderId: senderId)
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: senderId)
}
}
@@ -18,12 +18,12 @@ import SwiftUI
struct PlaceholderAvatarImage: View {
let firstCharacter: String
private let textForImage: String
var body: some View {
ZStack {
Color.element.accent
Text(firstCharacter)
Text(textForImage)
.padding(4)
.foregroundColor(.white)
// Make the text resizable (i.e. Make it large and then allow it to scale down)
@@ -32,6 +32,10 @@ struct PlaceholderAvatarImage: View {
}
.aspectRatio(1, contentMode: .fill)
}
init(text: String) {
textForImage = text.first?.uppercased() ?? ""
}
}
struct PlaceholderAvatarImage_Previews: PreviewProvider {
@@ -42,7 +46,7 @@ struct PlaceholderAvatarImage_Previews: PreviewProvider {
@ViewBuilder
static var body: some View {
PlaceholderAvatarImage(firstCharacter: "X")
PlaceholderAvatarImage(text: "X")
.clipShape(Circle())
.frame(width: 150, height: 100)
}
@@ -15,6 +15,7 @@ struct SeparatorRoomTimelineView: View {
var body: some View {
LabelledDivider(label: timelineItem.text)
.id(timelineItem.id)
.padding(.vertical, 8)
}
}
@@ -22,7 +23,7 @@ struct LabelledDivider: View {
let label: String
let color: Color
init(label: String, color: Color = .gray) {
init(label: String, color: Color = Color.element.secondaryContent) {
self.label = label
self.color = color
}
@@ -13,12 +13,15 @@ struct TextRoomTimelineView: View {
let timelineItem: TextRoomTimelineItem
var body: some View {
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
Text(timelineItem.text)
.font(.body)
.multilineTextAlignment(.leading)
}
}
.id(timelineItem.id)
@@ -36,19 +39,25 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
VStack(alignment: .leading, spacing: 20.0) {
TextRoomTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "Bob"))
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
shouldShowSenderDetails: true,
isOutgoing: true,
senderId: "Anne"))
}
.padding(.horizontal, 8)
}
private static func itemWith(text: String, timestamp: String, senderId: String) -> TextRoomTimelineItem {
private static func itemWith(text: String, timestamp: String, shouldShowSenderDetails: Bool, isOutgoing: Bool, senderId: String) -> TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: text,
timestamp: timestamp,
shouldShowSenderDetails: true,
senderId: senderId)
text: text,
timestamp: timestamp,
shouldShowSenderDetails: shouldShowSenderDetails,
isOutgoing: isOutgoing,
senderId: senderId)
}
}