Last active
August 3, 2020 07:51
-
-
Save CodeSlicing/f753cd311948690323da9569cb18fd97 to your computer and use it in GitHub Desktop.
Demo showing anime inspired chat bubbles, completely in SwiftUI. Requires Xcode 12 and PureSwiftUI 2.0.0-beta-1
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
| // | |
| // AnimeStyleChatDemo.swift | |
| // | |
| // Permission is hereby granted, free of charge, to any person obtaining a copy | |
| // of this software and associated documentation files (the "Software"), to deal | |
| // in the Software without restriction, including without limitation the rights | |
| // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies | |
| // of the Software, and to permit persons to whom the Software is furnished to do so, | |
| // subject to the following conditions: | |
| // | |
| // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| // PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR | |
| // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN | |
| // AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
| // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| // | |
| // | |
| // Created by Adam Fordyce on 02/08/2020. | |
| // Copyright © 2020 Adam Fordyce. All rights reserved. | |
| // | |
| import SwiftUI | |
| import PureSwiftUI | |
| private let senders: [Sender] = [ | |
| Sender(initials: "C.S", color: pastelBlue, sfSymbol: .line_3_crossed_swirl_circle_fill), | |
| Sender(initials: "T.C", color: pastelPurple, sfSymbol: .🍏applelogo), | |
| Sender(initials: "C.F", color: pastelYellow, sfSymbol: .moon_stars_fill), | |
| ] | |
| private let me = senders[0] | |
| private let conversation: [ConversationElement] = [ | |
| // Are you coming with me to buy the new iPhone? | |
| ConversationElement(message: "新しいiPhoneを購入するために同行しますか?", sender: senders[0]), | |
| // No way! A monster is destroying the city. Maybe tomorrow? | |
| ConversationElement(message: "ありえない! モンスターが街を破壊しています。 明日かな?", sender: senders[1]), | |
| // I've been waiting for this for a year. A monster is not going to stop me. Let's go! | |
| ConversationElement(message: "私はこれを1年間待っていました。 モンスターは私を止めるつもりはありません。 行こう!", sender: senders[0]), | |
| //Come on, Tim. Do not be afraid! | |
| ConversationElement(message: "さあ、ティム。 恐れることはありません!😘", sender: senders[2]), | |
| //But I ordered it online. It will arrive sometime this morning. This is not 2015 anymore! | |
| ConversationElement(message: "しかし、私はそれをオンラインで注文しました。 今朝いつか届きます。 これはもう2015年ではありません!", sender: senders[1]), | |
| //Have you heard about tradition? Standing in the queue is part of the overall experience! | |
| ConversationElement(message: "もちろんそうです! 何を考えていたのかわかりません。 買い物に行きましょう!", sender: senders[0]), | |
| //And to celebrate at the Apple Store! | |
| ConversationElement(message: "そして、Apple Storeで祝うために!🥳", sender: senders[2]), | |
| //Of course! I am ridiculous. Let's go shopping! | |
| ConversationElement(message: "もちろん! 私はばかげています。 買い物に行きましょう!👊🏻", sender: senders[1]), | |
| // Great. See you soon! | |
| ConversationElement(message: "素晴らしい。 また近いうちにお会いしましょう!", sender: senders[0]), | |
| //See you there! | |
| ConversationElement(message: "またね!", sender: senders[2]), | |
| ] | |
| private let messageWidth = UIScreen.mainWidth * 0.75 | |
| private let messageBgColor: Color = Color(#colorLiteral(red: 0.8980160356, green: 0.8980196118, blue: 0.8897935748, alpha: 1)) | |
| private let pastelBlue = Color(#colorLiteral(red: 0.5147306404, green: 0.6360479803, blue: 1, alpha: 1)) | |
| private let pastelPurple = Color(#colorLiteral(red: 0.8205519227, green: 0.6074476553, blue: 1, alpha: 1)) | |
| private let pastelYellow = Color(#colorLiteral(red: 0.9457640112, green: 0.8853469327, blue: 0.6038223113, alpha: 1)) | |
| private let bgColor = Color(red: 0.165, green: 0.169, blue: 0.156) | |
| private let backgroundPath = Path { path in | |
| let hexagonSize: CGFloat = 5 | |
| let hexagonLayoutGuide = LayoutGuideConfig.polar(rings: 1, segments: 6) | |
| let numColumns = UIScreen.mainWidthScaled(1 / hexagonSize) | |
| let numRows = UIScreen.mainHeightScaled(1 / hexagonSize) / 0.9 | |
| var g = hexagonLayoutGuide.layout(in: CGRect(0, 0, hexagonSize, hexagonSize)) | |
| for row in 0...(numRows.asInt + 1) { | |
| for column in 0...(numColumns.asInt + 1) { | |
| let offset = row.isOdd ? 0 : hexagonSize * 0.5 | |
| let origin = CGPoint(hexagonSize * column + offset, hexagonSize * row * 0.9) | |
| path.move(g[1, 0, origin: origin]) | |
| for segment in 1...g.yCount { | |
| path.line(g[1, segment, origin: origin]) | |
| } | |
| } | |
| } | |
| } | |
| struct AnimeStyleChatApp: View { | |
| var body: some View { | |
| ZStack { | |
| BackgroundView() | |
| .edgesIgnoringSafeArea(.all) | |
| ChatConversation() | |
| } | |
| .greedyFrame() | |
| } | |
| } | |
| private struct ChatConversation: View { | |
| var body: some View { | |
| ScrollView(showsIndicators: false) { | |
| VStack(spacing: 28) { | |
| ForEach(0..<conversation.count) { row in | |
| let element = conversation[row] | |
| let isMe = element.sender == me | |
| MessageRow(element: element, layoutDirection: isMe ? .rightToLeft : .leftToRight) | |
| } | |
| } | |
| .vPadding(20) | |
| } | |
| .greedyFrame() | |
| .hPadding(10) | |
| } | |
| } | |
| private struct MessageRow: View { | |
| @Environment(\.layoutDirection) private var systemLayoutDirection: LayoutDirection | |
| let element: ConversationElement | |
| let layoutDirection: LayoutDirection | |
| var body: some View { | |
| HStack(alignment: .bottom, spacing: 4) { | |
| IconView(sender: element.sender) | |
| .iconShadowStyle() | |
| .padding(.leading, 10) | |
| let initialsText = CaptionText(element.sender.initials, Font.Weight.black) | |
| initialsText | |
| .overlay(element.sender.color | |
| .overlay(LinearGradient([.white, .black], | |
| to: .bottom) | |
| .blendMode(.multiply) | |
| .opacity(0.6)) | |
| .mask(initialsText)) | |
| .iconShadowStyle() | |
| HStack(alignment: .bottom, spacing: 0) { | |
| MessageArrow(layoutDirection: layoutDirection) | |
| .fillColor(messageBgColor) | |
| .frame(15) | |
| .messageShadowStyle() | |
| .zIndex(layoutDirection == LayoutDirection.leftToRight ? 0 : 1) | |
| CaptionText(element.message, .thin).vPadding(12).hPadding(8) | |
| .messageStyle() | |
| .environment(\.layoutDirection, systemLayoutDirection) | |
| .padding(.trailing, 25) | |
| .zIndex(layoutDirection == LayoutDirection.leftToRight ? 1 : 0) | |
| } | |
| Spacer() | |
| } | |
| .environment(\.layoutDirection, layoutDirection) | |
| } | |
| } | |
| private let darkening = LinearGradient([ | |
| (.gray, 0), | |
| (.white, 0.4)], to: .bottom) | |
| private extension View { | |
| func messageStyle() -> some View { | |
| backgroundColor(messageBgColor) | |
| .overlay( | |
| darkening | |
| .opacity(0.5) | |
| .blur(5) | |
| .drawingGroup() | |
| .blendMode(.multiply) | |
| ) | |
| .clipRoundedRectangleWithStroke(5, messageBgColor, lineWidth: 3) | |
| .background(RoundedRectangle(5).messageShadowStyle()) | |
| } | |
| func messageShadowStyle() -> some View { | |
| shadowColor(.black, 2, offset: .point(5)) | |
| } | |
| func iconShadowStyle() -> some View { | |
| shadowColor(.black, 2, offset: .point(2)) | |
| } | |
| } | |
| private struct BackgroundView: View { | |
| var body: some View { | |
| backgroundPath | |
| .fill(Color(white: 0.1)) | |
| .background(Color(white: 0.2)) | |
| .overlay(LinearGradient([Color.white, Color.white, Color.clear], to: .bottomTrailing) | |
| .drawingGroup() | |
| .blendMode(.softLight) | |
| ) | |
| } | |
| } | |
| private let messageArrowLayoutConfig = LayoutGuideConfig.grid(columns: 10, rows: 10) | |
| private struct MessageArrow: Shape { | |
| let layoutDirection: LayoutDirection | |
| func path(in rect: CGRect) -> Path { | |
| var path = Path() | |
| var g = messageArrowLayoutConfig.layout(in: rect).scaled(CGSize(layoutDirection == LayoutDirection.leftToRight ? 1 : -1, 1)) | |
| path.move(g[0, 4]) | |
| path.curve(g[g.xCount, 3], cp1: g[6, 5], cp2: g[g.xCount, 3], showControlPoints: false) | |
| path.line(g[g.xCount, 8]) | |
| path.curve(g[0, 4], cp1: g[g.xCount - 2, 8], cp2: g[3, 7], showControlPoints: false) | |
| return path | |
| } | |
| } | |
| private struct IconView: View { | |
| let sender: Sender | |
| var body: some View { | |
| let roundedRectangle = RoundedRectangle(5) | |
| return Frame(30, sender.color) | |
| .overlay(SFSymbol(sender.sfSymbol) | |
| .resizedToFit().padding(3) | |
| .foregroundColor(.gray) | |
| .background(Color.white) | |
| .overlay( | |
| GlassShape() | |
| .fillColor(.gray) | |
| .blendMode(.colorBurn) | |
| ) | |
| .drawingGroup() | |
| .blendMode(.multiply) | |
| ) | |
| .overlay(roundedRectangle | |
| .strokeColor(.white, lineWidth: 1) | |
| .frame(34) | |
| .blur(0.5) | |
| .offset(.point(2)) | |
| .blendMode(.softLight) | |
| ) | |
| .overlay(roundedRectangle | |
| .strokeColor(.white, lineWidth: 4) | |
| .shadowColor(Color(white: 0.3), 3) | |
| .blendMode(.multiply) | |
| ) | |
| .overlay(roundedRectangle | |
| .strokeColor(Color(white: 0.1), lineWidth: 4) | |
| .blur(4) | |
| .drawingGroup() | |
| .blendMode(.multiply) | |
| .mask(GlassShape() | |
| .fillColor(.white) | |
| ) | |
| ) | |
| .clipShape(roundedRectangle.inset(by: 0.6)) | |
| } | |
| } | |
| private struct GlassShape: Shape { | |
| func path(in rect: CGRect) -> Path { | |
| var path = Path() | |
| path.move(rect.bottomLeading) | |
| path.line(0, rect.heightScaled(0.65)) | |
| path.line(rect.maxX, rect.heightScaled(0.35)) | |
| path.line(rect.bottomTrailing) | |
| path.closeSubpath() | |
| return path | |
| } | |
| } | |
| private struct ConversationElement { | |
| let message: String | |
| let sender: Sender | |
| } | |
| private struct Sender: Equatable { | |
| let initials: String | |
| let color: Color | |
| let sfSymbol: SFSymbolName | |
| } | |
| struct AnimeStyleChatApp_Previews: PreviewProvider { | |
| struct AnimeStyleChatApp_Harness: View { | |
| var body: some View { | |
| AnimeStyleChatApp() | |
| } | |
| } | |
| static var previews: some View { | |
| AnimeStyleChatApp_Harness() | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment