Skip to content

Commit

Permalink
feat: 🎸 [JIRA: HCPSDKFIORIUIKIT-2708] avatars enhancement (#773)
Browse files Browse the repository at this point in the history
* feat: 🎸 [JIRA: HCPSDKFIORIUIKIT-2708] avatars enhancement

* feat: 🎸 [JIRA: HCPSDKFIORIUIKIT-2708] update avatars layout

* fix: 🐛 default lines limit for footnote icons text
  • Loading branch information
xiaoyu0722 authored Aug 22, 2024
1 parent 03eda28 commit 39c1c4b
Show file tree
Hide file tree
Showing 19 changed files with 680 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
}

func numberOfRowsInSection(_ section: Int) -> Int {
4
self.isNewObjectItem ? 8 : 4
}

func titleForHeaderInSection(_ section: Int) -> String {
Expand Down Expand Up @@ -168,7 +168,7 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
Image(systemName: "person")
.resizable()
Text("XY")
.frame(width: 40, height: 40)
.frame(width: 30, height: 30)
.background(Color.red)
.foregroundColor(Color.white)
}, footnoteIcons: {
Expand All @@ -191,6 +191,72 @@ struct ObjectItemAvatarsExample: ObjectItemListDataProtocol {
.footnoteIconsSize(CGSize(width: 20, height: 20))
.isFootnoteIconsCircular(false)
return AnyView(oi)
case (0, 4):
let oi = ObjectItem {
Text("Title: This is a case for for long text with icons")
} subtitle: {
Text("Subtitle: this is a subtitle")
} footnoteIcons: {
Color.random
Color.random
Color.random
Color.random
Color.random
Color.random
} footnoteIconsText: {
Text("This is a very very very very very very very very very very very very very very very very very long text layout with footnote icons")
}
return AnyView(oi)
case (0, 5):
let oi = ObjectItem {
Text("This is a case for for short text with icons")
} subtitle: {
Text("Subtitle: this is a subtitle")
} footnoteIcons: {
Color.random
Color.random
Color.random
Color.random
Color.random
Color.random
} footnoteIconsText: {
Text("This is a short one.")
}
return AnyView(oi)
case (0, 6):
let oi = ObjectItem {
Text("This is a case for for long leading text with icons")
} subtitle: {
Text("Subtitle: this is a subtitle")
} footnoteIcons: {
Color.random
Color.random
Color.random
Color.random
Color.random
Color.random
} footnoteIconsText: {
Text("This is a very very very very very very very very very very very very very very very very very long text layout with footnote icons")
}.footnoteIconsTextPosition(.leading)
return AnyView(oi)
case (0, 7):
let oi = ObjectItem {
Text("This is a case for for short leading text with icons")
} subtitle: {
Text("Subtitle: this is a subtitle")
} footnoteIcons: {
Color.random
Color.random
Color.random
Color.random
Color.random
Color.random
} footnoteIconsText: {
Text("This is text with custom style.")
.font(.fiori(forTextStyle: .headline))
.foregroundStyle(Color.random)
}.footnoteIconsTextPosition(.leading)
return AnyView(oi)
default:
return AnyView(_ObjectItem(title: "Lorem ipseum dolor"))
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/FioriSwiftUICore/Models/ModelDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public protocol AvatarStackModel: AvatarsComponent {}
// sourcery: add_env_props = "footnoteIconsSpacing"
// sourcery: add_env_props = "isFootnoteIconsCircular"
// sourcery: add_env_props = "footnoteIconsMaxCount"
// sourcery: add_env_props = "footnoteIconsTextPosition"
// sourcery: add_env_props = "footnoteIconsText"
public protocol FootnoteIconStackModel: FootnoteIconsComponent {}

// sourcery: add_env_props = "horizontalSizeClass"
Expand Down
40 changes: 24 additions & 16 deletions Sources/FioriSwiftUICore/Views/CustomBuilder/AvatarsBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,37 @@ public protocol AvatarList: View, _ViewEmptyChecking {
public extension AvatarList {
/// :nodoc:
@ViewBuilder func buildAvatar(_ avatar: V) -> some View {
Group {
if isCircular {
avatar
.frame(width: size.width, height: size.height)
.clipShape(Capsule())
.overlay {
Capsule()
.inset(by: borderWidth / 2.0)
.stroke(borderColor, lineWidth: borderWidth)
}
} else {
avatar
.frame(width: size.width, height: size.height)
.border(borderColor, width: borderWidth)
}
if isCircular {
avatar
.frame(width: size.width, height: size.height)
.clipShape(Capsule())
.overlay {
Capsule()
.inset(by: borderWidth / 2.0)
.stroke(borderColor, lineWidth: borderWidth)
}
} else {
avatar
.frame(width: size.width, height: size.height)
.border(borderColor, width: borderWidth)
}
}

// This condition check if for handle recursive builder issue.
private func checkIsNestingAvatars() -> Bool {
let typeString = String(describing: V.self)
return typeString.contains("SingleAvatar<SingleAvatar") || typeString.contains("SingleAvatar<PairAvatar") || typeString.contains("PairAvatar<")
}

/// :nodoc:
var body: some View {
Group {
if count == 1 {
self.buildAvatar(view(at: 0))
if self.checkIsNestingAvatars() {
self.view(at: 0)
} else {
self.buildAvatar(view(at: 0))
}
} else if count >= 2 {
ZStack(alignment: .topLeading) {
self.buildAvatar(view(at: 0))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,7 @@ public protocol FootnoteIconList: View, _ViewEmptyChecking {
public extension FootnoteIconList {
/// :nodoc:
var body: some View {
HStack(spacing: spacing) {
let itemsCount = maxCount <= 0 ? count : min(count, maxCount)
ForEach(0 ..< itemsCount, id: \.self) { index in
view(at: index)
.frame(width: size.width, height: size.height)
.ifApply(isCircular) {
$0.clipShape(Capsule())
}
.overlay {
Group {
if isCircular {
Capsule()
.inset(by: 0.33 / 2.0)
.stroke(Color.preferredColor(.separator), lineWidth: 0.33)
} else {
Rectangle()
.inset(by: 0.33 / 2.0)
.stroke(Color.preferredColor(.separator), lineWidth: 0.33)
}
}
}
}
}
.clipped()
FootnoteIconsListView(icons: self)
}
}

Expand Down Expand Up @@ -150,6 +127,7 @@ public struct PairFootnoteIcon<First: View, Second: FootnoteIconList>: FootnoteI
@Environment(\.isFootnoteIconsCircular) var isFootnoteIconsCircular
@Environment(\.footnoteIconsSpacing) var footnoteIconsSpacing
@Environment(\.footnoteIconsSize) var footnoteIconsSize

public var maxCount: Int {
self.footnoteIconsMaxCount
}
Expand Down Expand Up @@ -333,7 +311,26 @@ public extension EnvironmentValues {
}
}

struct FootnoteIconsTextPosition: EnvironmentKey {
static let defaultValue: TextPosition = .trailing
}

public extension EnvironmentValues {
/// Text position for footnote icons.
var footnoteIconsTextPosition: TextPosition {
get { self[FootnoteIconsTextPosition.self] }
set { self[FootnoteIconsTextPosition.self] = newValue }
}
}

public extension View {
/// Specific the position of the text that drawn for footnote icons. Default value is `.trailing`.
/// - Parameter position: Text position.
/// - Returns: A view that footnote icons text with specific position.
func footnoteIconsTextPosition(_ position: TextPosition) -> some View {
environment(\.footnoteIconsTextPosition, position)
}

/// Maximum number of the footnote icons. Default value is 0. When the count is less or equal to 0, means the number is unlimited.
/// ```swift
/// _ObjectItem(title: "Object Item",
Expand All @@ -359,7 +356,7 @@ public extension View {
/// .isFootnoteIconsCircular(false)
/// ```
/// - Parameter isCircular: Boolean denoting whether the footnote icons are circular.
/// - Returns: A view that footnote icons are cirlcular or not.
/// - Returns: A view that footnote icons are circular or not.
func isFootnoteIconsCircular(_ isCircular: Bool) -> some View {
environment(\.isFootnoteIconsCircular, isCircular)
}
Expand Down Expand Up @@ -394,3 +391,28 @@ public extension View {
environment(\.footnoteIconsSize, size)
}
}

/// Text position for icons.
public enum TextPosition {
/// Top position for text.
case top
/// Bottom position for text.
case bottom
/// Leading position for text.
case leading
/// Trailing position for text.
case trailing

var alignment: Alignment {
switch self {
case .top:
return .top
case .bottom:
return .bottom
case .leading:
return .leading
case .trailing:
return .trailing
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import SwiftUI

struct FootnoteIconsListView<T: FootnoteIconList>: View {
let icons: T

var count: Int {
self.icons.count
}

var maxCount: Int {
self.icons.maxCount
}

var size: CGSize {
self.icons.size
}

var isCircular: Bool {
self.icons.isCircular
}

var spacing: CGFloat {
self.icons.spacing
}

// This condition check if for handle recursive builder issue.
func checkIsNestingIcons() -> Bool {
let typeString = String(describing: T.self)
return typeString.contains("SingleFootnoteIcon<SingleFootnoteIcon") || typeString.contains("SingleFootnoteIcon<PairFootnoteIcon") || typeString.contains("PairFootnoteIcon<")
}

var body: some View {
if self.count == 1, self.checkIsNestingIcons() {
self.icons.view(at: 0)
} else {
self.avatarsView()
}
}

@ViewBuilder
func avatarsView(withText: Bool = false) -> some View {
FootnoteIconsHStack(spacing: self.spacing) {
let itemsCount = self.maxCount <= 0 ? self.count : min(self.count, self.maxCount)
ForEach(0 ..< itemsCount, id: \.self) { index in
self.icons.view(at: index)
.frame(width: self.size.width, height: self.size.height)
.ifApply(self.isCircular) {
$0.clipShape(Capsule())
}
.overlay {
Group {
if self.isCircular {
Capsule()
.inset(by: 0.33 / 2.0)
.stroke(Color.preferredColor(.separator), lineWidth: 0.33)
} else {
Rectangle()
.inset(by: 0.33 / 2.0)
.stroke(Color.preferredColor(.separator), lineWidth: 0.33)
}
}
}
}
}
}
}

struct FootnoteIconsHStack: Layout {
struct CacheData {
var width: CGFloat
var count: Int
var size: CGSize
}

let spacing: CGFloat

func makeCache(subviews: Subviews) -> CacheData {
CacheData(width: 0, count: 0, size: .zero)
}

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize {
self.calculateSizeAndCount(proposal: proposal, subviews: subviews, cache: &cache)
return cache.size
}

func calculateSizeAndCount(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {
guard let contentWidth = proposal.width, cache.width != contentWidth else {
return
}
cache.width = contentWidth

var totalWidth: CGFloat = 0
var maxHeight: CGFloat = 0

for (index, subview) in subviews.enumerated() {
let subviewSize = subview.sizeThatFits(proposal)
maxHeight = max(maxHeight, subviewSize.height)
if subviewSize.width + totalWidth <= contentWidth {
totalWidth += subviewSize.width
totalWidth += self.spacing
} else {
cache.count = index
cache.size = CGSize(width: totalWidth, height: maxHeight)
break
}
}
totalWidth -= self.spacing
cache.count = subviews.count
cache.size = CGSize(width: totalWidth, height: maxHeight)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {
var xOffset: CGFloat = bounds.minX
self.calculateSizeAndCount(proposal: proposal, subviews: subviews, cache: &cache)
for (index, subview) in subviews.enumerated() {
if index < cache.count {
let subviewSize = subview.sizeThatFits(proposal)
subview.place(at: CGPoint(x: xOffset, y: bounds.minY),
proposal: ProposedViewSize(CGSize(width: subviewSize.width, height: subviewSize.height)))
xOffset += (subviewSize.width + self.spacing)
} else {
break
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ protocol _FootnoteIconsComponent {
var footnoteIcons: [TextOrIcon] { get }
}

// sourcery: BaseComponent
protocol _FootnoteIconsTextComponent {
// sourcery: @ViewBuilder
var footnoteIconsText: AttributedString? { get }
}

// sourcery: BaseComponent
protocol _AvatarsComponent {
// sourcery: resultBuilder.name = @AvatarsBuilder, resultBuilder.backingComponent = AvatarStack
Expand Down
Loading

0 comments on commit 39c1c4b

Please sign in to comment.