-
-
Save janodev/9aac6d40b8dc3e3c71894bd82ae096c4 to your computer and use it in GitHub Desktop.
SwiftUI View Tree Graph
This file contains 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
#!/opt/homebrew/bin/python3 | |
import re | |
import glob | |
import os | |
import graphviz | |
import sys | |
## USAGE ######################################################################### | |
# 1. Install | |
# brew install python3 | |
# pip install graphviz | |
# 2. Set this to the name of your main SwiftUI view file | |
MAIN_FILE = 'RootView.swift' | |
# 4. Call passing the folder containing your sources, e.g. | |
# python3 viewtree.py Sources/Application | |
os.environ["PATH"] += os.pathsep + "/opt/homebrew/bin" | |
# Ignore any `PreferenceKey` and `EnvironmentKey` types | |
DROP_KEYS = True | |
# Ignore anything that isn't a `View` | |
DROP_NON_VIEWS = True | |
# Style subgraphs differently | |
STYLE_SUBGRAPH = False | |
################################################################################## | |
class Node: | |
def __init__(self, name, children, inline_children): | |
self.name = name | |
self.children = children | |
self.inline_children = inline_children | |
found = [] | |
def find_main_file(folder, main_file_name): | |
for root, dirs, files in os.walk(folder): | |
if main_file_name in files: | |
return os.path.join(root, main_file_name) | |
return None | |
def analyze(file, folder): | |
base = os.path.basename(file) | |
if DROP_NON_VIEWS and not base.endswith("View.swift"): | |
return None | |
found.append(file) | |
base = os.path.basename(file) | |
viewname = os.path.splitext(base)[0] | |
children = [] | |
inline_structs = [] | |
with open(file) as f: | |
lines = f.readlines() | |
for rawline in lines: | |
line = rawline.strip() | |
if line.strip("private").strip("public").strip().startswith("struct"): | |
regex_result = re.search("struct ([A-Za-z0-9_]*)", line) | |
if regex_result: | |
name = regex_result.group(1) | |
if name != viewname and name not in inline_structs and "_" not in name: | |
isView = "View" in line | |
if not DROP_NON_VIEWS or isView: | |
iskey = "PreferenceKey" in line or "EnvironmentKey" in line | |
if not DROP_KEYS or not iskey: | |
inline_structs.append(name) | |
in_view = False | |
for rawline in lines: | |
line = rawline.strip() | |
is_comment = line.startswith("/*") or line.startswith("*") or line.startswith("//") or line.startswith("import") | |
is_property_decl = " var " in line or " let " in line | |
if not is_comment and not is_property_decl: | |
regex_result = re.search(r"(?!\.)([A-Z][a-zA-z0-9]*)(?!\.)\s*(\(|{)", line) | |
if regex_result: | |
name = regex_result.group(1) | |
if name not in inline_structs and "_" not in name: | |
for f in glob.glob(os.path.join(folder, '**/' + name + '.swift'), recursive=True): | |
if f != file: | |
child = analyze(f, folder) | |
children.append(child) | |
return Node(viewname, children, inline_structs) | |
# keep track of already created edges since we don't care about multiple edges between the same nodes | |
edgesIDs = [] | |
def graph(dot, node): | |
if node is None: | |
return | |
dot.node(node.name) | |
for child in node.children: | |
if child is not None: | |
id = node.name + child.name | |
if id not in edgesIDs: | |
edgesIDs.append(id) | |
dot.edge(node.name, child.name) | |
graph(dot, child) | |
else: | |
continue | |
if len(node.inline_children) > 0: | |
with dot.subgraph(name='cluster_' + node.name) as c: | |
if STYLE_SUBGRAPH: | |
c.node_attr['shape'] = 'egg' | |
c.node_attr['fontcolor'] = 'darkgray' | |
c.node_attr['style'] = '' | |
c.graph_attr['style'] = 'dotted' | |
for inline in node.inline_children: | |
id = node.name + inline | |
if id not in edgesIDs: | |
edgesIDs.append(id) | |
c.node(inline) | |
c.edge(node.name, inline) | |
def run(): | |
if len(sys.argv) != 2: | |
print("Usage: script.py <folder>") | |
sys.exit(1) | |
folder = sys.argv[1] | |
main_file_path = find_main_file(folder, MAIN_FILE) | |
if not main_file_path: | |
print(f"Error: {MAIN_FILE} not found in {folder}") | |
sys.exit(1) | |
main_node = analyze(main_file_path, folder) | |
if main_node is None: | |
print("No valid View-based main file found.") | |
return | |
dot = graphviz.Digraph(comment='View Tree From: ' + main_node.name, node_attr={'shape': 'box', 'style': 'filled'}) | |
graph(dot, main_node) | |
if STYLE_SUBGRAPH: | |
source = dot | |
else: | |
source = dot.unflatten(stagger=3) | |
# uncomment this to keep the dot file. Dot files can be edited in Omnigraffle. | |
# dot.save('./swiftui-views.dot') | |
source.render('./swiftui-views', format='png', view=True) | |
if os.path.exists('./swiftui-views'): | |
os.remove('./swiftui-views') | |
run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Changes
View.swift