Plain timeline styler (#94)

* #92 Create `TimelineStyle` and store it in user defaults

* #92 Create `TimelineStyler` observable environment object

* #92 Use timeline list row insets from the styler

* #92 remove sender view, move header into the styler views

* #92 Add timeline style option in settings

* #92 Add changelog

* #92 refactor some code duplications

* #92 Fix `TimelineStyle`targets

* #92 Fix `TimelineStyle` targets

* #92 Fix PR remarks

* #92 Fix inline PR comments

* #92 Fix UI tests

* #92 Rename bubbled to bubbles
This commit is contained in:
ismailgulek
2022-06-27 13:27:09 +03:00
committed by GitHub
parent b6b8b4be26
commit 36c34503fc
21 changed files with 386 additions and 234 deletions
@@ -8,14 +8,10 @@
import Foundation
import SwiftUI
import Combine
import Introspect
struct TimelineItemStylerView<Header: View, Content: View>: View {
struct TimelineItemBubbledStylerView<Content: View>: View {
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let header: () -> Header
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
@@ -24,7 +20,7 @@ struct TimelineItemStylerView<Header: View, Content: View>: View {
var body: some View {
VStack(alignment: timelineItem.isOutgoing ? .trailing : .leading, spacing: -5) {
if !timelineItem.isOutgoing {
header()
header
.zIndex(1)
}
if timelineItem.isOutgoing {
@@ -42,6 +38,24 @@ struct TimelineItemStylerView<Header: View, Content: View>: View {
}
}
@ViewBuilder
private var header: some View {
if timelineItem.shouldShowSenderDetails {
VStack {
Spacer()
.frame(height: 8)
HStack(alignment: .top, spacing: 4) {
TimelineSenderAvatarView(timelineItem: timelineItem)
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
.font(.body)
.foregroundColor(.element.primaryContent)
.fontWeight(.semibold)
.lineLimit(1)
}
}
}
}
@ViewBuilder
var styledContent: some View {
if shouldAvoidBubbling {
@@ -85,7 +99,7 @@ struct TimelineItemStylerView<Header: View, Content: View>: View {
}
struct TimelineItemStylerView_Previews: PreviewProvider {
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
@@ -94,65 +108,13 @@ struct TimelineItemStylerView_Previews: PreviewProvider {
@ViewBuilder
static var body: some View {
VStack(alignment: .leading) {
TimelineItemStylerView(timelineItem: item1) {
EventBasedTimelineSenderView(timelineItem: item1)
} content: {
Text(item1.text)
}
TimelineItemStylerView(timelineItem: item2) {
EventBasedTimelineSenderView(timelineItem: item2)
} content: {
Text(item2.text)
}
TimelineItemStylerView(timelineItem: item3) {
EventBasedTimelineSenderView(timelineItem: item3)
} content: {
Text(item3.text)
}
TimelineItemStylerView(timelineItem: item4) {
EventBasedTimelineSenderView(timelineItem: item4)
} content: {
Text(item4.text)
ForEach((1..<MockRoomTimelineController().timelineItems.count), id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
}
}
.timelineStyle(.bubbles)
.padding(.horizontal, 8)
.frame(maxHeight: 400)
.previewLayout(.sizeThatFits)
}
private static var item1: TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: "Short",
timestamp: "07:05",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "Bob")
}
private static var item2: TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: "Short loin ground round tongue hamburger, fatback salami shoulder.",
timestamp: "08:05",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "Bob")
}
private static var item3: TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: "Short loin ground round tongue hamburger, fatback salami shoulder.",
timestamp: "08:07",
shouldShowSenderDetails: false,
isOutgoing: true,
senderId: "Bob")
}
private static var item4: TextRoomTimelineItem {
return TextRoomTimelineItem(id: UUID().uuidString,
text: "Short",
timestamp: "08:08",
shouldShowSenderDetails: false,
isOutgoing: true,
senderId: "Bob")
}
}
@@ -0,0 +1,60 @@
//
// TimelineItemStyleView.swift
// ElementX
//
// Created by Ismail on 21.06.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct TimelineItemPlainStylerView<Content: View>: View {
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading) {
header
content()
}
}
@ViewBuilder
private var header: some View {
if timelineItem.shouldShowSenderDetails {
HStack {
TimelineSenderAvatarView(timelineItem: timelineItem)
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
.font(.body)
.foregroundColor(.element.primaryContent)
.fontWeight(.semibold)
.lineLimit(1)
Text(timelineItem.timestamp)
.foregroundColor(Color.element.tertiaryContent)
.font(.element.caption2)
}
}
}
}
struct TimelineItemPlainStylerView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
@ViewBuilder
static var body: some View {
VStack(alignment: .leading) {
ForEach((1..<MockRoomTimelineController().timelineItems.count), id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item)
}
}
.timelineStyle(.plain)
.padding(.horizontal, 8)
.previewLayout(.sizeThatFits)
}
}
@@ -0,0 +1,44 @@
//
// TimelineStyle.swift
// ElementX
//
// Created by Ismail on 24.06.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
enum TimelineStyle: String, CaseIterable {
case plain
case bubbles
/// List row insets for a timeline
var listRowInsets: EdgeInsets {
switch self {
case .plain:
return EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 20)
case .bubbles:
return EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8)
}
}
}
// MARK: - Environment
private struct TimelineStyleKey: EnvironmentKey {
static let defaultValue = BuildSettings.defaultRoomTimelineStyle
}
extension EnvironmentValues {
var timelineStyle: TimelineStyle {
get { self[TimelineStyleKey.self] }
set { self[TimelineStyleKey.self] = newValue }
}
}
extension View {
func timelineStyle(_ style: TimelineStyle) -> some View {
environment(\.timelineStyle, style)
}
}
@@ -0,0 +1,28 @@
//
// TimelineStyler.swift
// ElementX
//
// Created by Ismail on 24.06.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
// MARK: - TimelineStyler
struct TimelineStyler<Content: View>: View {
@Environment(\.timelineStyle) private var style
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content
var body: some View {
switch style {
case .plain:
TimelineItemPlainStylerView(timelineItem: timelineItem, content: content)
case .bubbles:
TimelineItemBubbledStylerView(timelineItem: timelineItem, content: content)
}
}
}
@@ -13,18 +13,14 @@ struct EmoteRoomTimelineView: View {
let timelineItem: EmoteRoomTimelineItem
var body: some View {
VStack(alignment: .leading) {
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)
}
TimelineStyler(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)
.foregroundColor(.element.primaryContent)
}
}
}
@@ -1,93 +0,0 @@
//
// 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")
}
}
@@ -14,43 +14,37 @@ struct ImageRoomTimelineView: View {
var body: some View {
if timelineItem.image != nil || timelineItem.blurhash != nil { // Fixes view heights after loading finishes
VStack(alignment: .leading) {
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)) {
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
if let aspectRatio = timelineItem.aspectRatio {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
.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(timelineItem.aspectRatio, contentMode: .fit)
}
}
.id(timelineItem.id)
.animation(.default, value: timelineItem.image)
.frame(maxHeight: 1000.0)
} else {
VStack(alignment: .leading) {
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
HStack {
Spacer()
ProgressView("Loading")
Spacer()
}
TimelineStyler(timelineItem: timelineItem) {
HStack {
Spacer()
ProgressView("Loading")
Spacer()
}
}
.id(timelineItem.id)
}
}
}
@@ -13,18 +13,14 @@ struct NoticeRoomTimelineView: View {
let timelineItem: NoticeRoomTimelineItem
var body: some View {
VStack(alignment: .leading) {
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)
}
TimelineStyler(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)
.foregroundColor(.element.primaryContent)
}
}
}
@@ -13,9 +13,7 @@ struct TextRoomTimelineView: View {
let timelineItem: TextRoomTimelineItem
var body: some View {
TimelineItemStylerView(timelineItem: timelineItem) {
EventBasedTimelineSenderView(timelineItem: timelineItem)
} content: {
TimelineStyler(timelineItem: timelineItem) {
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(attributedComponents: attributedComponents)
} else {
@@ -42,12 +40,26 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "Bob"))
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
shouldShowSenderDetails: true,
isOutgoing: true,
senderId: "Anne"))
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"))
.timelineStyle(.plain)
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
shouldShowSenderDetails: true,
isOutgoing: true,
senderId: "Anne"))
.timelineStyle(.plain)
}
.padding(.horizontal, 8)
}
@@ -15,6 +15,7 @@ struct TimelineItemList: View {
@State private var tableViewObserver: ListTableViewAdapter = ListTableViewAdapter()
@State private var timelineItems: [RoomTimelineViewProvider] = []
@State private var hasPendingChanges = false
@ObservedObject private var settings = ElementSettings.shared
@ObservedObject var context: RoomScreenViewModel.Context
@@ -42,7 +43,7 @@ struct TimelineItemList: View {
})
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8))
.listRowInsets(settings.timelineStyle.listRowInsets)
.onAppear {
context.send(viewAction: .itemAppeared(id: timelineItem.id))
}
@@ -56,6 +57,7 @@ struct TimelineItemList: View {
}
}
.listStyle(.plain)
.timelineStyle(settings.timelineStyle)
.environment(\.defaultMinListRowHeight, 0.0)
.introspectTableView { tableView in
if tableView == tableViewObserver.tableView {
@@ -0,0 +1,39 @@
//
// TimelineSenderAvatarView.swift
// ElementX
//
// Created by Ismail on 24.06.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct TimelineSenderAvatarView: View {
let timelineItem: EventBasedTimelineItemProtocol
@ScaledMetric private var avatarSize = 26
var body: 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)
}
}